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:
pip install masks-clientA 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.
Setting up
Section titled “Setting up”Every framework takes the same masks.web.Config. Most apps set four things:
issuer, the masks tenant that signs people in, such ashttps://acme.auth.example.comresource, the app’s own absolutehttpsURLname, the name masks shows for the appsecret, at least 32 random bytes that seal the session, such as the output ofopenssl rand -hex 32. Django and Flask use the app’s own secret key when their settings leave it out. AConfigmade without a secret raisesValueError, unless it setssessions.
See every setting.
After you set up your framework:
- Set
MASKS_ISSUERto the tenant you sign in against. - Start the app.
- Open
/auth/handshake.
The handshake
Section titled “The handshake”The handshake connects your app to the masks server:
- The browser goes to the masks server’s approval screen.
- You approve the app there.
- 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/callbackas its redirect URI./auth/handshake/callbackas the address masks sends people back to after the handshake./auth/logout/returnas the address masks sends people back to after signing out of masks./auth/logout/backchannelfor 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.
Several tenants
Section titled “Several tenants”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
issueris a function,Configneedscredentialsas well, since onemasks.jsonwould 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.
Managing the connection
Section titled “Managing the connection”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
managesis 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.
Signing in
Section titled “Signing in”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 anonce. - It checks the
issthat 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.
Refreshing
Section titled “Refreshing”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
5xxor429status, leaves the session as it is. The person counts as signed out for that request only.
Signing out
Section titled “Signing out”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.
Back-channel logout
Section titled “Back-channel logout”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 amasks.Logout, hears each logout as well.
Sessions
Section titled “Sessions”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, andSameSite=Lax. - The session is replaced at every sign-in.
- It lasts 14 days, or
session_lifetimein 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.
Running several processes
Section titled “Running several processes”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 redisfrom 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.
Organizations
Section titled “Organizations”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.organizationis the organization signed in to, with itsid,key,name, androle. It isNonewhen there is none.actor.organizationslists 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.
Privileges
Section titled “Privileges”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": [...]}. roletakes one role or several. Any one of them is enough.organizationlimits 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.
Accepting tokens
Section titled “Accepting tokens”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.
resourceis the audience that tokens must name. The audience is the app a token is meant for.resource_scopeslists 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.
Delegations
Section titled “Delegations”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.DelegationUnavailablemeans masks or the provider did not answer, and nothing was lost.masks.DelegationRefusedmeans masks refused the call.sign_in_againis true when the person has to sign in again.- Both carry the replacement in
secretwhen masks had already replaced it.
Machines and enrollment
Section titled “Machines and enrollment”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:
-
The app asks masks for an initial access token:
client = await auth.client(request)enrollment = await client.enroll(scope=["catalog:agent"], client_name="nas") -
The app hands
enrollment.initial_access_tokento the machine. -
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.