Skip to content

Python apps

masks-client signs people in to a Python web app against a masks server. It covers what Masks::Rails covers in a Rails app: the handshake, the sign-in, a session, organizations, privileges, and checking access tokens. It works with FastAPI, Django, and Flask.

The package imports as masks and needs Python 3.10 or later. Install it from PyPI:

Terminal window
pip install masks-client

A web app adds the extra for its framework, such as masks-client[fastapi]. Each framework has its own page:

masks-client lists every setting, route, and call. The examples on this page use FastAPI. Each framework page lists the same calls for that framework.

Every framework takes the same masks.web.Config. Most apps set four things:

  • issuer, the masks tenant that signs people in, such as https://acme.auth.example.com
  • resource, the app’s own absolute https URL
  • name, the name masks shows for the app
  • secret, at least 32 random bytes that seal the session, such as the output of openssl rand -hex 32. Django and Flask use the app’s own secret key when their settings leave it out. A Config made without a secret raises ValueError, unless it sets sessions.

See every setting.

After you set up your framework:

  1. Set MASKS_ISSUER to the tenant you sign in against.
  2. Start the app.
  3. Open /auth/handshake.

The handshake connects your app to the masks server:

  1. The browser goes to the masks server’s approval screen.
  2. You approve the app there.
  3. A one-time token comes back. The app redeems it by talking to masks directly, so the client secret never passes through the browser.

The handshake registers the app for resource, with these addresses:

  • /auth/callback as its redirect URI.
  • /auth/handshake/callback as the address masks sends people back to after the handshake.
  • /auth/logout/return as the address masks sends people back to after signing out of masks.
  • /auth/logout/backchannel for back-channel logout, when the issuer supports it.

All of them must share the resource’s origin, or the handshake raises ValueError before the browser leaves. A credential store that cannot be written refuses the handshake before masks registers anything.

The app writes its credentials to masks.json with file mode 600. To keep them somewhere else, set credentials to one of the credential stores.

A multi-tenant app sets credentials to a function that takes the request and returns the store for that tenant. issuer, resource, name, organization, and base_url take a function of the request in the same way.

  • When issuer is a function, Config needs credentials as well, since one masks.json would send one tenant’s client secret to another.
  • The handshake saves the issuer with the credentials, and the app never uses them with another issuer.
  • Derive each value from something the app trusts, such as a tenant it looked up by host. Never use a value the browser sends without checking it.

Until the app is connected, anybody who opens /auth/handshake starts the handshake. Once it is connected, /auth/handshake shows the connection to somebody manages allows, with a Reconnect button and a Disconnect button. Disconnect deletes the registration at masks and the credentials.

  • A person who is not signed in goes to after_sign_out.
  • Anybody else is refused with 403.
  • If manages is not set, everybody is refused.
Config(..., manages="admin")
Config(..., manages=lambda actor: actor.subject == os.environ["OWNER_SUB"])

Before each sign-in, the app checks that masks still knows it. When masks does not, the app forgets its credentials and the browser goes to the handshake again. Credentials that cannot be written, such as StaticCredentials, are kept.

A scope is a named permission that a token holds. A claim is one piece of information inside a token, such as the person’s email address.

The routes answer under prefix, which is /auth by default. GET /auth starts a sign-in, and GET /auth/callback finishes it. See every route.

A sign-in is protected in these ways:

  • It uses PKCE, a state, and a nonce.
  • It checks the iss that masks sends back.
  • It checks the ID token’s signature, audience, nonce, and at_hash.

Up to five sign-ins can be in progress in one browser, each for an hour. A callback the browser did not start sends it to sign in again, at most once a minute. In between, it is refused with invalid_state. return_to is followed only to a path on the app.

A JSON request that needs a sign-in gets 401 with login_required or handshake_required and the URL to send the browser to. A browser is redirected.

The actor holds the sign-in’s claims, scopes, organizations, and privileges. See the actor.

auth.login_url(return_to="/billing") builds a link to sign in, and auth.logout_url() is the path a sign-out form posts to. In Django these are masks.django.login_url and the /auth/logout path.

A token that expires within 30 seconds is refreshed with its refresh token. The person’s name, photo, and organizations are read again from userinfo, the masks endpoint that describes the signed-in person.

masks revokes a refresh token that is used twice, along with every token issued from it. So requests that arrive together with the same expired session share one refresh. The first request spends the refresh token, and the others get its result from store for a minute.

  • A refresh that masks refuses signs the person out.
  • A refresh that masks does not answer, or answers with a 5xx or 429 status, leaves the session as it is. The person counts as signed out for that request only.

A sign-out revokes the refresh token and clears the session. With sign_out_of_issuer, or everywhere in the request, the browser goes on to masks to end its session there. masks sends it back to /auth/logout/return, which sends it on to after_sign_out. That return works once, within an hour, and only in the browser that signed out.

A sign-out of any method is refused with 403 when its Sec-Fetch-Site header is anything other than same-origin or none, or its Origin header names another origin. A POST or DELETE that sends neither header is refused too.

A GET sign-out signs out at once when its Sec-Fetch-Site header is same-origin or none. A GET with no Sec-Fetch-Site header, as older browsers send, does not sign out. A browser gets a page with a Sign out button that posts to the same address, query included, so it keeps everywhere. A JSON request gets 403. So a link on another site cannot sign anybody out.

When masks ends a session itself, it sends a logout token to /auth/logout/backchannel. The app checks the token, then signs out the session it names, or every session of the person it names. The sign-out takes effect at that session’s next request.

  • A logout token must have an expiry. The app takes each one once, and a token sent again gets 400.
  • The app remembers each logout in store, so it reaches every process that shares the store. When the store cannot save the logout, the app answers with an error and takes the same token when masks sends it again.
  • Sessions kept on the server are also deleted from the session store.
  • logged_out, a function that takes a masks.Logout, hears each logout as well.

The session lives in a cookie named __Host-masks, so no other host can set it. The cookie is sealed with AES-GCM under a key derived from secret. The browser cannot read the tokens inside it, and /auth/session leaves them out.

  • The cookie is HttpOnly, Secure, and SameSite=Lax.
  • The session is replaced at every sign-in.
  • It lasts 14 days, or session_lifetime in seconds.
  • To change the secret, pass a list with the new secret first. Sessions sealed with an older secret still open, and are sealed with the new one the next time they change.

A session larger than one cookie is split across up to eight cookies, named __Host-masks, __Host-masks-1, and so on. Past about 30 KB, the app raises ValueError. To keep sessions on the server, set sessions:

from masks.web import MemorySessionStore, ServerSessions
Config(..., sessions=ServerSessions(MemorySessionStore()))

The cookie then holds only a random id. The store keeps each session under a hash of that id, so the ids cannot be read back from the store. MemorySessionStore keeps sessions in one process. A store of your own, such as one in Redis or a database, has the session store methods.

Each process remembers some things in memory by default:

  • the result of each token refresh, so requests that arrive together refresh once
  • the logouts masks sent, so a cookie session ends in every process
  • the logout tokens and DPoP proofs already used

An app that runs as several processes, such as under gunicorn with more than one worker, sets store to one they share. Without it, two processes can refresh the same token at once. masks then treats the second refresh as a stolen token and revokes the tokens from both, so the person is signed out.

import redis
from masks.web import RedisStore
Config(..., store=RedisStore(redis.Redis.from_url(os.environ["REDIS_URL"])))

In FastAPI, use AsyncRedisStore with redis.asyncio.Redis. A store of your own has the store methods.

When an app asks for the organization scope, a person signs in as a member of one organization. You can name the organization in two ways:

  • For every sign-in, with organization.
  • For one sign-in, with /auth?organization=acme.
Config(
...,
scope=["openid", "profile", "email", "offline_access", "organization"],
organization=lambda request: request.url.hostname.split(".")[0],
)
  • actor.organization is the organization signed in to, with its id, key, name, and role. It is None when there is none.
  • actor.organizations lists every organization the person has joined, with the role held in each.
  • actor.has_role("owner", "billing") says whether the person holds one of those roles in the organization signed in to.
  • auth.login_url(organization="globex") is a link that signs the person in again to another organization.

The app reads the organizations from userinfo at sign-in and at every refresh. So when a person’s role changes, the app sees the change within the lifetime of one access token.

A privilege is a name for what a route needs. It can ask for:

  • scopes the person holds in masks
  • a role in the organization they signed in to
  • both
Config(
...,
privileges={
"admin": ["catalog:admin"],
"billing": {"role": ["owner", "billing"]},
"acme_admin": {"scope": ["catalog:admin"], "role": "owner", "organization": "acme"},
},
)
  • A list is a short way to write {"scope": [...]}.
  • role takes one role or several. Any one of them is enough.
  • organization limits the privilege to one organization, named by key or id.
The sign-in holds the privilege The route runs.
A scope is missing The browser goes through masks once. It asks for the usual scopes and the privilege’s scopes, then comes back to the page it was on. A JSON request gets 403 with privilege_required, the privileges, and the login_url to send the browser to.
masks did not grant the scope on that trip 403 insufficient_scope, when the person comes back within five minutes. The person does not hold the scope, so the browser does not go back to masks in a loop.
A role or organization does not match 403 insufficient_role or insufficient_organization. The browser does not go through masks.

An ordinary sign-in never asks for privilege scopes. Asking for them never gives anyone more access, since masks puts a scope in the token only for a person who already holds it. The handshake registers the scopes of every privilege, so the issuer lets the app ask for them. Set namespace, such as catalog:, to register the namespace in place of each scope under it.

A guard that names a privilege privileges does not define raises ValueError in the app. In a URL, the app ignores it.

A resource server is an app that accepts access tokens from other apps. Guard an API route with protect in FastAPI, token_required in Django, or protected in Flask.

  • resource is the audience that tokens must name. The audience is the app a token is meant for.
  • resource_scopes lists the scopes the app accepts. A dict of scope to description also gives masks the sentence its consent screen shows for each scope.
@app.get("/api/items")
async def items(actor: Annotated[Actor, Depends(auth.protect("read"))]):
return {"for": actor.subject}

The guard takes the same privileges, including roles and organizations, and checks them against the token. The actor for a token has by_token set, and its claims are the token’s claims.

No token 401 with RFC 6750 challenges for Bearer and DPoP that carry no error code.
A token that fails a check 401 with RFC 6750 challenges for Bearer and DPoP that carry invalid_token.
A token that lacks a privilege 403 with insufficient_scope, insufficient_role, or insufficient_organization.
The issuer’s keys or the replay store cannot be reached 503 with no challenge, since the token may still be good.

Each challenge’s resource_metadata points at /.well-known/oauth-protected-resource, which the routes serve.

A token can be bound to a key with DPoP. DPoP lets only the holder of a private key use the token. A bound token is accepted only as Authorization: DPoP, with a DPoP proof that:

  • is signed by the token’s key
  • names this request’s method, URL, and token
  • was made within the last 60 seconds, with 30 seconds of leeway for clocks that differ
  • has not been seen before

The URL is the resource’s origin and the request’s path, so a proxy in front of the app needs no other setting. The app remembers the proofs it has seen in store.

An API with no browser sign-in can use masks.Verifier without masks.web. See checking tokens.

A delegation lets the app act as a person at a provider while they are away. Set delegation_redirect_uri to the path the provider’s answer comes back to. The handshake then asks for masks:delegate: and registers that address.

Config(..., delegation_redirect_uri="/connect/callback")
@app.get("/connect")
async def connect(request: Request):
return await auth.start_delegation(request, "google")
@app.get("/connect/callback")
async def connected(request: Request, actor: Annotated[Actor, Depends(auth.require())]):
held = await auth.finish_delegation(request)
save(actor.subject, held.connection, held.secret)

start_delegation sends a visitor who is not signed in to sign in first. It also takes prompt and max_age, which it sends to masks with the request. finish_delegation raises masks.DelegationRefused when:

  • this browser did not start the connection, or it is more than an hour old
  • someone else signed in after it started
  • masks connected the provider for another account
  • masks’ answer fails a check
  • the person said no at the provider

Keep the secret encrypted. To call the provider later, take the app’s client and ask for the provider’s token:

client = await auth.client(request)
upstream = await client.delegated_token(secret, connection=connection)
save(actor.subject, connection, upstream.secret)

Save upstream.secret before anything else, since each call replaces the secret.

  • masks.DelegationUnavailable means masks or the provider did not answer, and nothing was lost.
  • masks.DelegationRefused means masks refused the call. sign_in_again is true when the person has to sign in again.
  • Both carry the replacement in secret when masks had already replaced it.

A Python program on a machine, such as an agent an app installs, signs in as itself with masks.Client. See machines for keys, client credentials, DPoP, and the device flow.

An app that has claimed a namespace, through namespace in its handshake, enrolls each machine without a browser:

  1. The app asks masks for an initial access token:

    client = await auth.client(request)
    enrollment = await client.enroll(scope=["catalog:agent"], client_name="nas")
  2. The app hands enrollment.initial_access_token to the machine.

  3. The machine makes its own keys and registers with the token, as machines shows.

await client.unenroll(machine_client_id) archives the machine and revokes its tokens. See enrolling a machine.