Skip to content

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 example ACCESS_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:

  • default resolves to the workspace with is_default = True. If none exists, the dependency returns None rather than raising.
  • current resolves to the workspace whose id is account.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.
  • None from 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.

See also