msflib-auth¶
Purpose¶
msflib-auth issues access tokens and gives your host app the FastAPI dependencies that identify the caller. It covers password login, logout (token revocation), password recovery, optional Google OAuth, and two dependency factories: one that resolves the current account from a bearer token, and one that resolves the current workspace and the account's membership in it.
It does not define your account or workspace models. You pass them in, typically the ones from msflib-account and msflib-workspaces.
For the end-to-end picture of how a request becomes a scoped identity, see Scopes, tenancy and workspaces.
Install¶
[tool.poetry.dependencies]
msflib = { git = "https://github.com/msflib/fastapi.git", subdirectory = "core", rev = "core-v0.2.1" }
msflib-auth = { git = "https://github.com/msflib/fastapi.git", subdirectory = "modules/auth", rev = "auth-v0.2.2" }
The package depends on authlib and itsdangerous. It imports no other MSFLib module, so it works with any SQLModel account model that has email, hashed_password and status columns.
msflib/auth/__init__.py exports nothing. Import from the submodules: msflib.auth.deps, msflib.auth.router, msflib.auth.resolvers, msflib.auth.config, msflib.auth.eventbus.
Wiring into a host app¶
Build the dependency namespaces once at startup, then mount the router.
from fastapi import FastAPI
from msflib.auth.deps import get_account_dependencies, get_user_dependencies
from msflib.auth.resolvers import PathParameterResolver
from msflib.auth.router import router as auth_router
account_deps = get_account_dependencies(
AccountModel=Account,
oauth_token_url="/api/v1/auth/login",
secret_key=settings.SECRET_KEY,
session_dep=get_session,
keystore_dep=get_keystore,
)
# Only needed when your app has workspaces.
user_deps = get_user_dependencies(
UserModel=User,
WorkspaceModel=Workspace,
session_dep=get_session,
account_dependencies=account_deps,
workspace_resolver=PathParameterResolver(),
)
app = FastAPI()
app.include_router(
auth_router(
get_session=get_session,
get_keystore=get_keystore,
get_current_account=account_deps.get_current_account,
account_type=Account,
account_read_type=AccountRead,
settings=settings,
prefix="/api/v1/auth",
)
)
What you supply:
| Argument | Meaning |
|---|---|
session_dep / get_session |
A FastAPI dependency yielding a SQLModel Session. |
keystore_dep / get_keystore |
A dependency returning a StoreInterface (for example msflib.core.store.MapStore). Used to record revoked accounts. |
secret_key |
The key that signs and verifies JWTs. Use the same value as settings.SECRET_KEY; the router signs with the latter. |
settings |
Your settings object. The router reads the AUTH namespace from it and SECRET_KEY as a flat attribute (settings.SECRET_KEY). |
Known issue (#287)
The router reads settings.SECRET_KEY from the top level of the settings object. With fully composed settings (core only under CORE, as in the quick-start layout) POST /auth/login and the password reset endpoints raise AttributeError, although get_account_dependencies(secret_key=settings.scope("CORE").SECRET_KEY) itself works. Mix CoreSettings into your settings class (class Settings(CoreSettings, SettingsBase), with the other modules still nested) so SECRET_KEY is a top-level attribute.
get_account_dependencies also accepts active_statuses (default ["active", "online"]) and account_role_attr (default "role"). get_user_dependencies accepts user_role_attr (default "type"), current_workspace_attr (default "current_workspace_id") and workspace_resolver.
Routes added by the router¶
| Route (under the prefix) | Behaviour |
|---|---|
POST /login |
OAuth2 password form (username is the email). Returns access_token, expires, token_type, account and user. |
DELETE /logout |
Revokes the caller's token. |
POST /password-recovery |
Emails a reset link and a short code. |
POST /verify-token |
Checks a reset token or a short code plus email. |
POST /reset-password |
Sets a new password from a valid reset token. |
GET /google/login, POST /google/callback |
Google OAuth. Return 403 unless ENABLE_GOOGLE_OAUTH is true. |
The router takes optional hooks: access_token_claims_decorator(account, context) adds claims to the JWT, access_token_decorator(payload, context) rewrites the login response, send_reset_pwd_email_override replaces the recovery email sender, access_token_response_type replaces the Token response model, and auth_payload_type validates extra data a client sends to the Google callback.
Configuration¶
Settings live in the AUTH namespace (msflib.auth.config.AuthSettings).
| Key | Default | Notes |
|---|---|---|
ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
Lifetime of tokens issued at login. |
EMAIL_RESET_TOKEN_EXPIRE_HOURS |
48 |
Lifetime of password reset tokens and codes. |
PASSWORD_RESET_PATH |
/reset |
Appended to CORE.CLIENT_HOST in the reset email link. |
ENABLE_GOOGLE_OAUTH |
False |
Enables the Google routes. |
ALLOW_GOOGLE_OAUTH_SIGNUP |
False |
Create an account for an unknown Google email. Requires a listener for google-account-create. |
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET |
empty | Read when the router is built. |
GOOGLE_REDIRECT_URI |
empty | Default redirect when the client does not send one. |
How you set these from the environment depends on how you compose settings:
- Subclassed (
class Settings(AuthSettings, CoreSettings, SettingsBase), as used by the repository's test site): use the flat field name, for exampleACCESS_TOKEN_EXPIRE_MINUTES=60. - Composed style (
AUTH: AuthSettings = AuthSettings()as a field): use the nested form,AUTH__ACCESS_TOKEN_EXPIRE_MINUTES=60. The flat names also work for every key in the table above.
Either way, code reads values with settings.scope("AUTH").
Behaviour may change (#280)
The nested NS__KEY environment style only works for composed (field) settings, not for subclassed hosts. Use the flat field name with subclassed settings, as described above.
Key concepts¶
Account dependencies¶
get_account_dependencies(...) returns a namespace with:
| Member | Behaviour |
|---|---|
get_current_account |
Decodes the bearer token, rejects it with 401 if invalid or revoked, loads the account by email == token.sub, and 404s if it does not exist. |
get_current_account_or_none |
Same, but returns None when no token is sent. For endpoints that serve both anonymous and signed-in callers. |
get_current_active_account |
Also requires account.status in active_statuses; otherwise 401. |
get_current_active_superuser |
Also requires account.role == "root"; otherwise 403. This check is hard-coded and ignores account_role_attr (see the notice below). |
RoleCheck(roles) |
A callable class for dependencies=[Depends(RoleCheck([...]))]. Passes only when the account's role attribute is in roles; otherwise 403. It does not check the account's status; add Depends(get_current_active_account) to require an active account. |
Known issue (#272)
get_current_active_superuser hard-codes account.role == "root" and ignores account_role_attr. If your role attribute or superuser role differs, require both get_current_active_account and RoleCheck([...]) with the roles you want. RoleCheck alone depends on get_current_account, so it also admits inactive accounts that have a matching role.
RoleCheck is an exact membership test. RoleCheck(["admin"]) does not admit root; list every role you want to allow.
Workspace resolution¶
get_user_dependencies(...) turns a request into a workspace and a membership. The strategy for finding the workspace is a WorkspaceResolver, chosen once at startup.
A resolver receives a ResolverContext (session, account, path_params, headers) and returns a workspace slug, or None when the request does not identify one. Header keys in headers are lower-case. Two resolvers ship in msflib.auth.resolvers:
| Resolver | Reads | Default |
|---|---|---|
PathParameterResolver(param_name=...) |
A path parameter | workspace_slug |
HttpHeaderResolver(header_name=...) |
A request header | X-Workspace |
If you pass no resolver, PathParameterResolver() is used. HttpHeaderResolver also declares the header as an optional parameter on the dependencies, so it shows up in OpenAPI.
Once the resolver returns a slug, the dependency looks the workspace up:
defaultresolves to the workspace withis_default = True. If none exists, the dependency returnsNonerather than raising.currentresolves to the workspace whose id isaccount.current_workspace_id. It is a 404 for anonymous callers and for accounts with no current workspace.- Any other value is matched against
Workspace.slug; 404 if not found. Nonefrom the resolver is a 404 with "No workspace specified."
The namespace then exposes:
| Member | Behaviour |
|---|---|
get_current_workspace |
Requires an authenticated account (get_current_account), then resolves the workspace as above. |
get_current_workspace_anonymous |
Same resolution without authentication. |
get_current_user |
Finds the UserModel row with account_id == account.id and workspace_id == workspace.id. A missing row is a 403: authenticated, but not a member of this workspace. |
get_current_active_user |
Also requires user.status == "active"; otherwise 401. |
WorkspaceRoleCheck(roles) |
Passes when the membership's role attribute (type by default) is in roles; otherwise 403. |
Behaviour may change (#269)
get_current_workspace_anonymous does no membership or status check. It only maps a slug to a workspace row. Use it for deliberately public reads, and do not return anything from it that a non-member should not see.
Membership is the only path to a workspace. get_current_user never grants access because the account owns the tenant or has a platform role; the account needs a membership row in that specific workspace. See workspaces for how rows are created.
A custom resolver¶
Subclass WorkspaceResolver for any other strategy, such as a subdomain or a claim on the account:
from msflib.auth.resolvers import ResolverContext, WorkspaceResolver
class SubdomainResolver(WorkspaceResolver):
def resolve(self, context: ResolverContext) -> str | None:
host = context.headers.get("host", "")
return host.split(".")[0] if host.count(".") >= 2 else None
Pass an instance as workspace_resolver=. A resolver is a plain synchronous object, so it is easy to unit-test with a hand-built ResolverContext.
One resolver applies to everything built by one get_user_dependencies call. To use two strategies in one app, call the factory twice and use each namespace on its own routers.
Token revocation¶
Tokens are JWTs whose sub is the account email. Logout stores the email in the keystore, and get_current_account rejects any token whose sub is in the keystore. Revocation therefore applies to the account rather than to individual tokens, and logging in again clears it. If you need to end a single session, put a unique claim in the token with access_token_claims_decorator and check it in your own dependency.
Events¶
Event names are in msflib.auth.eventbus.EventName:
| Event | Emitted when |
|---|---|
account-logged-in |
A login or Google callback succeeds. Optional: no listener is fine. |
google-account-create |
A Google login names an unknown email and ALLOW_GOOGLE_OAUTH_SIGNUP is true. Required: a listener must create the account, or the callback returns 500. |
Register the listener on the app emitter, for example @app_emitter.on("google-account-create") with app_emitter = bind_app_emitter(app). A listener added with emitter.on(...) at import time lands on the default emitter, which the router does not see, and the callback returns 500 "No listener". Sync and async listeners on the app emitter both work.
See Event bus for registering listeners.
Examples¶
Protect routes by account role and workspace membership¶
This runs against an in-memory SQLite database using the models from msflib-account and msflib-workspaces. member and guest are workspace roles; the owner passes WorkspaceRoleCheck.
from datetime import timedelta
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from msflib.account.models.account import Account
from msflib.auth.deps import get_account_dependencies, get_user_dependencies
from msflib.auth.resolvers import PathParameterResolver
from msflib.core.security import create_access_token
from msflib.core.store import MapStore
from msflib.workspaces.models import UserType
from msflib.workspaces.models.user import User
from msflib.workspaces.models.workspace import Workspace
# get_session, get_keystore and SECRET_KEY come from your app; see "Wiring".
account_deps = get_account_dependencies(
AccountModel=Account,
oauth_token_url="/login",
secret_key=SECRET_KEY,
session_dep=get_session,
keystore_dep=get_keystore,
)
user_deps = get_user_dependencies(
UserModel=User,
WorkspaceModel=Workspace,
session_dep=get_session,
account_dependencies=account_deps,
workspace_resolver=PathParameterResolver(),
)
app = FastAPI()
@app.get("/{workspace_slug}/whoami")
def whoami(
workspace=Depends(user_deps.get_current_workspace),
user=Depends(user_deps.get_current_active_user),
):
return {"workspace": workspace.slug, "user_id": user.id, "type": user.type}
@app.get(
"/{workspace_slug}/admin",
dependencies=[Depends(user_deps.WorkspaceRoleCheck([UserType.owner, UserType.admin]))],
)
def admin_only():
return {"ok": True}
@app.get("/{workspace_slug}/public")
def public(workspace=Depends(user_deps.get_current_workspace_anonymous)):
return {"workspace": workspace.slug}
token = create_access_token("ada@example.com", SECRET_KEY, timedelta(minutes=5))
client = TestClient(app)
client.get("/acme/whoami", headers={"Authorization": f"Bearer {token}"})
# 200 {"workspace": "acme", "user_id": 1, "type": "owner"} (ada is a member)
# 403 {"detail": "User not found in specified workspace"} (a non-member)
# GET /nope/public -> 404 "Workspace not found"
Resolve the workspace from a header¶
from msflib.auth.resolvers import HttpHeaderResolver
user_deps = get_user_dependencies(
UserModel=User,
WorkspaceModel=Workspace,
session_dep=get_session,
account_dependencies=account_deps,
workspace_resolver=HttpHeaderResolver("X-Workspace"),
)
Routes no longer carry a {workspace_slug} path segment. A client sends X-Workspace: acme; without the header the dependency responds 404 "No workspace specified.". X-Workspace: current selects the account's current_workspace_id, and X-Workspace: default the default workspace.
Add claims and extra response fields at login¶
The response model filters what the login route returns, so extra fields need a response type that declares them.
from msflib.auth.models import Token
class LoginResponse(Token):
environment: str = ""
app.include_router(
auth_router(
get_session=get_session,
get_keystore=get_keystore,
get_current_account=account_deps.get_current_account,
account_type=Account,
account_read_type=AccountRead,
settings=settings,
access_token_response_type=LoginResponse,
access_token_claims_decorator=lambda account, ctx: {
"email_domain": account.email.split("@")[1]
},
access_token_decorator=lambda payload, ctx: {**payload, "environment": "staging"},
)
)
ctx is {"session": session}. The JWT now carries an email_domain claim and the login body includes environment. The workspaces module ships WorkspaceAction.build_access_token_claims and decorate_access_token_payload, which are designed for these two hooks and add the active workspace and membership.
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
| 401 "Could not validate credentials" | The token is malformed, expired, or signed with a different key. secret_key passed to get_account_dependencies must equal settings.SECRET_KEY used by the router. |
| 401 "Token expired" right after logout | Expected: logout revoked the account. A new login clears it. |
| 404 "No workspace specified." | The resolver returned None: the path parameter name does not match the route, or the header is missing. |
| 404 "Current workspace unknown for anonymous user." | The slug current was used on an anonymous dependency. |
| 403 "User not found in specified workspace" | The account has no membership row in that workspace. Create one (see workspaces). |
403 on an admin route for a root account |
RoleCheck and WorkspaceRoleCheck match exactly. Add root to the list, or use get_current_active_superuser. |
| Google callback returns 500 "No listener for Google account creation" | ALLOW_GOOGLE_OAUTH_SIGNUP is on but nothing listens to google-account-create. Register a listener that creates the account. |
| Reset email contains the wrong link host | The link is CORE.CLIENT_HOST plus AUTH.PASSWORD_RESET_PATH. Set both. |
API reference¶
See the generated API reference for msflib.auth. modules/auth/README.md covers the Google callback payload in more detail.