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:
- Rejects an unknown email or a wrong password with
400 Incorrect email or password, and an account whose status is not active with400 Inactive account. - Sets
last_login_dateif the account has that attribute. - Removes the account's email from the keystore, which re-enables the account if it had logged out (see Logging out).
- Emits the
account-logged-inevent (msflib.auth.eventbus.EventName.ACCOUNT_LOGGED_IN). - 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_decoratorand 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:
POST /password-recoverywith{"email": ...}creates a reset token and a short numeric code, stores the token in the keystore under the code, and emails both. It answers404when no account has that email.POST /verify-tokenwith{"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.POST /reset-passwordwith{"token": ..., "email": ..., "new_password": ...}sets the password.emailis required whentokenis 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'sSessionMiddlewareto the app, as the module's own tests do.POST /google/callbacktakes 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 theworkspace_slugpath parameter, so routes look like/api/v1/{workspace_slug}/projects.PathParameterResolver(param_name="slug")renames it.HttpHeaderResolver()readsX-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
WorkspaceResolverand implementsresolve(context), returning a slug orNone.ResolverContextcarriessession,account,path_paramsandheaders(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_KEYsetting 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_PASSWORDfrom its default.