Skip to content

Authentication

This guide follows a request from login to a protected route, then covers token customisation, password recovery, Google sign-in and workspaces. The code comes from msflib-auth and works with the Account table from msflib-account. The examples reuse the host app built in Writing custom code: settings, Account, get_session, get_keystore and auth_deps are the names defined there, and app_emitter comes from its main.py.

The pieces

Piece Where Job
Auth router msflib.auth.router.router(...) POST /login, DELETE /logout, password recovery, Google sign-in
Account dependencies msflib.auth.deps.get_account_dependencies(...) Verify the bearer token on each request and load the account
Workspace dependencies msflib.auth.deps.get_user_dependencies(...) Find the active workspace and the caller's membership in it
Token and password helpers msflib.core.security HS256 JWTs and bcrypt hashing
Keystore msflib.api.deps.get_keystore_factory(...) A small key store that records revoked accounts

A token is a JWT signed with the SECRET_KEY setting (the CORE namespace). The auth router reads it from the top level of your settings object, so use the subclassed style. Its sub claim is the account's email. Nothing else is needed to identify the caller, so the account is loaded from the database on every request.

Known issue (#287)

With composed (nested) settings the auth router fails on POST /login, POST /password-recovery and POST /reset-password with AttributeError: ... no attribute 'SECRET_KEY'. Adding a top-level SECRET_KEY stops the error but leaves two independent values (SECRET_KEY and CORE__SECRET_KEY), so tokens signed by the router can be rejected by the dependencies. Use the subclassed style.

1. Wire it up

You create the dependency namespace once and pass pieces of it to the routers. This is the setup from the previous guide:

from msflib.api.deps import get_keystore_factory, get_session_factory
from msflib.auth.deps import get_account_dependencies
from msflib.auth.router import router as auth_router

get_session = get_session_factory(engine)
get_keystore = get_keystore_factory()

auth_deps = get_account_dependencies(
    AccountModel=Account,
    oauth_token_url="/api/v1/login",
    secret_key=settings.scope("CORE").SECRET_KEY,
    session_dep=get_session,
    keystore_dep=get_keystore,
)

app.include_router(
    auth_router(
        get_session=get_session,
        get_keystore=get_keystore,
        get_current_account=auth_deps.get_current_account,
        account_type=Account,
        account_read_type=AccountRead,
        settings=settings,
        prefix="",
    ),
    prefix="/api/v1",
)

The secret passed to get_account_dependencies and the one in settings must be the same value, since one signs and the other verifies. active_statuses (on both the router and the dependencies) lists the account statuses that count as active; the default is ["active", "online"]. Pass the same list to both.

2. Log in

POST /login takes an OAuth2 password form, so the fields are form-encoded and the email goes in username.

r = client.post("/api/v1/login", data={"username": "a@example.com", "password": "pw"})
token = r.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}

The response has access_token, expires, token_type (bearer), account (serialised with your account_read_type) and user (null unless you add workspaces, see Workspaces).

On a successful login the router:

  1. Rejects an unknown email or a wrong password with 400 Incorrect email or password, and an account whose status is not active with 400 Inactive account.
  2. Sets last_login_date if the account has that attribute.
  3. Removes the account's email from the keystore, which re-enables the account if it had logged out (see Logging out).
  4. Emits the account-logged-in event (msflib.auth.eventbus.EventName.ACCOUNT_LOGGED_IN).
  5. Signs a token that expires after AUTH.ACCESS_TOKEN_EXPIRE_MINUTES (default 30).

Listen to the login event to keep an audit trail or start a session record:

from msflib.auth.eventbus import EventName


@app_emitter.on(EventName.ACCOUNT_LOGGED_IN)
def on_login(account, options):
    ...  # options["session"] is the request's database session

There is no refresh token. When a token expires, the client logs in again.

3. Protect routes

get_account_dependencies returns these members:

Dependency Passes when Otherwise
get_current_account A valid, unexpired token whose account is not revoked 401 Could not validate credentials, 401 Token expired (revoked), or 404 Account not found
get_current_account_or_none Same, and returns None when no Authorization header is sent A bad or revoked token is still an error
get_current_active_account get_current_account and the account's status is in active_statuses 401 The account is inactive
get_current_active_superuser Active, and role == "root" 403 Not authorized
RoleCheck(roles) The account's role is in roles 403 Not authorized
from fastapi import Depends
from msflib.account.models import AccountRole


@app.get("/whoami")
def whoami(account=Depends(auth_deps.get_current_active_account)):
    return {"email": account.email}


@app.get(
    "/reports",
    dependencies=[
        Depends(auth_deps.get_current_active_account),
        Depends(auth_deps.RoleCheck([AccountRole.admin])),
    ],
)
def reports():
    return {"ok": True}


@app.get("/greeting")
def greeting(account=Depends(auth_deps.get_current_account_or_none)):
    return {"hello": account.email if account else "stranger"}

Use get_current_active_account for most routes. get_current_account alone does not check the status, so a suspended account with a valid token still passes it. RoleCheck depends on get_current_account, not the active variant, so combine them when both matter. AccountRole has root, admin and user; the superuser dependency checks for root only, so an admin needs RoleCheck.

Known issue (#272)

get_current_active_superuser hard-codes role == "root" and ignores account_role_attr. If your superuser role has another name or you also want admin to pass, require both get_current_active_account and RoleCheck([...]). RoleCheck alone depends on get_current_account, so it also admits inactive accounts that have a matching role.

Because the account is looked up by the email in the token, changing an account's email invalidates its outstanding tokens (the next request answers 404 Account not found).

Log out and revocation

DELETE /logout (requires a token) stores the account's email in the keystore. From then on every token for that account is rejected with 401 Token expired, until the account logs in again, which removes the key.

Two things to know:

  • Revocation applies to the account rather than to individual tokens: logging out ends the account's current sessions, and logging in again clears the revocation. To end a single session, add a unique claim with access_token_claims_decorator and check it in your own dependency.
  • With the default in-memory keystore, revocation is per process. Use Redis when you run more than one worker:
get_keystore = get_keystore_factory(
    redis_host="redis.internal", redis_password="...", redis_port=6379
)

If redis_host or redis_password is missing, or the Redis client cannot be created, the factory falls back to an in-memory store and logs a warning. The keystore also holds the short password-reset codes below.

Customise the token

The router takes four hooks for the login response:

Argument Receives Returns
access_token_claims_decorator (account, {"session": session}) A dict merged into the JWT claims
access_token_decorator (payload, {"session": session}) The response payload dict, which you can extend
access_token_response_type A subclass of msflib.auth.models.Token used as the response model
account_read_type The schema used for the account field
app.include_router(
    auth_router(
        ...,
        access_token_claims_decorator=lambda account, ctx: {"role": str(account.role)},
    ),
    prefix="/api/v1",
)

After this, the decoded token contains {"sub": "a@example.com", "role": "user", "exp": ...}. Claims are for the client's benefit; the server side does not read them back, since get_current_account loads the account on each request. Do not put anything secret in them: a JWT is signed, not encrypted.

Fields you add to the payload with access_token_decorator only appear in the response if the response model declares them, so pair it with an access_token_response_type.

Password recovery

Three endpoints implement recovery:

  1. POST /password-recovery with {"email": ...} creates a reset token and a short numeric code, stores the token in the keystore under the code, and emails both. It answers 404 when no account has that email.
  2. POST /verify-token with {"token": ..., "email": ...} checks a token. Either the long token or the short code may be sent; a value shorter than 10 characters is treated as a code and looked up with the email.
  3. POST /reset-password with {"token": ..., "email": ..., "new_password": ...} sets the password. email is required when token is a short code. The account must be active.

The email is built from the template reset_password.html and sent through the core email service, using the SMTP values in the core settings. The link points at CORE.CLIENT_HOST plus AUTH.PASSWORD_RESET_PATH (default /reset) with ?token=.... Reset tokens last AUTH.EMAIL_RESET_TOKEN_EXPIRE_HOURS (default 48).

To send the email yourself, or to capture it in tests, pass send_reset_pwd_email_override. It is called with keyword arguments email_to, username, token and code:

outbox = []

app.include_router(
    auth_router(
        ...,
        send_reset_pwd_email_override=lambda **kw: outbox.append((kw["token"], kw["code"])),
    ),
    prefix="/api/v1",
)

client.post("/api/v1/password-recovery", json={"email": "a@example.com"})
token, code = outbox[-1]
client.post(
    "/api/v1/reset-password",
    json={"token": code, "email": "a@example.com", "new_password": "new"},
)

Google sign-in

Not run in this guide

This section follows the source and the module's tests. It needs real Google credentials, so it was not exercised end to end.

Google sign-in is off by default. Set AUTH.ENABLE_GOOGLE_OAUTH, GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET and GOOGLE_REDIRECT_URI (flat environment names work with the subclassed settings style). While the flag is off, both Google routes answer 403.

Behaviour may change (#280)

The nested NS__KEY environment style only works for composed (field) settings, not for subclassed hosts. With subclassed settings, use the flat names.

  • GET /google/login?redirect_uri=... redirects the browser to Google. The OAuth state is kept in the request session, so add Starlette's SessionMiddleware to the app, as the module's own tests do.
  • POST /google/callback takes a JSON body {"code": ..., "redirect_uri": ..., "auth_data": ...}, exchanges the code, reads the Google profile and returns the same token response as /login.

If no account has the Google email, the router creates none by itself. With AUTH.ALLOW_GOOGLE_OAUTH_SIGNUP on, it emits google-account-create and requires a listener to create the account; with no listener it answers 500. With the flag off, it answers 400 and the user must register normally. The listener receives a GoogleUserInfo and {"session": session}. Register it on the app emitter (app_emitter.on); a listener on the global emitter is not seen, and the request still answers 500:

import secrets

from msflib.auth.eventbus import EventName


@app_emitter.on(EventName.GOOGLE_ACCOUNT_CREATE)
async def create_google_account(user_info, options):
    session = options["session"]
    aa.create(
        session,
        data=AccountCreate(email=user_info.email, password=secrets.token_urlsafe(32)),
    )

To collect extra data from the client on first sign-in, pass auth_payload_type= (a Pydantic class). The callback validates auth_data against it before calling Google and puts the result in user_info.auth_payload.

Workspaces

An account can belong to several workspaces. The workspace dependencies answer two questions per request: which workspace is this for, and is the caller a member?

from msflib.auth.deps import get_user_dependencies
from msflib.auth.resolvers import HttpHeaderResolver

user_deps = get_user_dependencies(
    UserModel=WorkspaceMember,
    WorkspaceModel=Workspace,
    session_dep=get_session,
    account_dependencies=auth_deps,
    workspace_resolver=HttpHeaderResolver(),
)

WorkspaceMember and Workspace are tables defined from the msflib-workspaces bases (see Overriding models); the module's own Workspace and User tables work too if you do not need extra columns.

user_deps provides:

Dependency Passes when Otherwise
get_current_workspace Valid account token, and a workspace is resolved 404 No workspace specified., 404 Workspace not found
get_current_workspace_anonymous Same without a token Same
get_current_user The account is a member of the workspace 403 User not found in specified workspace
get_current_active_user The membership status is active 401 User is not active
WorkspaceRoleCheck(roles) The membership type is in roles (owner, admin, member, guest) 403 Not authorized
@app.get("/api/v1/projects")
def projects(
    workspace=Depends(user_deps.get_current_workspace),
    user=Depends(user_deps.get_current_active_user),
):
    return {"workspace": workspace.slug, "member_type": user.type}


@app.get(
    "/api/v1/ws-admin",
    dependencies=[Depends(user_deps.WorkspaceRoleCheck(["owner", "admin"]))],
)
def ws_admin():
    return {"ok": True}

With the header resolver, a client sends X-Workspace: acme along with the bearer token. Without the header the route answers 404 No workspace specified., an unknown slug answers 404 Workspace not found, and a non-member gets 403 on ws-admin. Two slugs are special in every resolver: current is the workspace in the account's current_workspace_id, and default is the workspace marked is_default.

Choose how the workspace is found

A resolver turns the request into a workspace slug. Pick one when you create user_deps:

  • PathParameterResolver() (the default) reads the workspace_slug path parameter, so routes look like /api/v1/{workspace_slug}/projects. PathParameterResolver(param_name="slug") renames it.
  • HttpHeaderResolver() reads X-Workspace; HttpHeaderResolver(header_name="X-Active-Workspace") renames it. The header shows up as an optional header parameter in the OpenAPI document.
  • A custom resolver subclasses WorkspaceResolver and implements resolve(context), returning a slug or None. ResolverContext carries session, account, path_params and headers (header names lower-cased).
from msflib.auth.resolvers import ResolverContext, WorkspaceResolver


class SubdomainResolver(WorkspaceResolver):
    def __init__(self, base_domain: str) -> None:
        self.suffix = "." + base_domain

    def resolve(self, context: ResolverContext) -> str | None:
        host = context.headers.get("host", "").split(":")[0]
        return host.removesuffix(self.suffix) if host.endswith(self.suffix) else None


sub_deps = get_user_dependencies(
    UserModel=WorkspaceMember,
    WorkspaceModel=Workspace,
    session_dep=get_session,
    account_dependencies=auth_deps,
    workspace_resolver=SubdomainResolver("example.com"),
)

A request to acme.example.com resolves the workspace acme. The resolver is chosen per user_deps instance, so different route groups can use different strategies by building several instances.

Include the workspace in the token

The workspaces module has two helpers that put the caller's workspace context into the login response and the JWT. Build them from your actions and pass them to the auth router:

from msflib.auth.models import Token


class WorkspaceToken(Token):
    workspaces: list[dict] = []
    current_workspace_id: int | None = None


auth_router(
    ...,
    access_token_claims_decorator=lambda account, ctx: wa.build_access_token_claims(
        ctx["session"], account=account, user_action=ua
    ),
    access_token_decorator=lambda payload, ctx: wa.decorate_access_token_payload(
        ctx["session"], payload=payload, account_action=aa, user_action=ua
    ),
    access_token_response_type=WorkspaceToken,
)

The token then carries user_id and workspace_id claims, and the response lists the caller's workspaces and sets current_workspace_id (the account's first workspace is chosen if it has none, or if the stored one is not among its memberships). If you leave out access_token_response_type, the default Token model keeps only account and user and drops the other fields (see the notice below). The server still resolves the workspace per request from the resolver, not from the claims.

Known issue (#271)

The fields added by decorate_access_token_payload are dropped by the default Token response model. Always pass an access_token_response_type that declares them, as WorkspaceToken does above.

Creating the default workspace and membership when an account registers is done by msflib.workspaces.eventbus.register_event_hooks(...), described on the workspaces page.

Testing authenticated routes

Override the session and keystore as shown in Writing custom code (create the MapStore once, not per request, so that logout revocation is visible), create an account through its action, and log in through the test client. To skip the HTTP login, create a token directly with msflib.core.security.create_access_token(account.email, secret_key) and send it as the bearer value.

Operational notes

  • Set the SECRET_KEY setting explicitly and identically in every worker. The default is regenerated on each start.
  • Serve the API over TLS. Bearer tokens are valid for anyone who holds them until they expire or the account logs out.
  • Use a shared keystore (Redis) before relying on logout.
  • Change FIRST_SUPERUSER_PASSWORD from its default.