FastAPI sessions
keenauth.bff.fastapi adds sign-in to a FastAPI application. Your server is the OAuth client and keeps every token on the server. The browser gets only an encrypted, HttpOnly session cookie and never sees an access, refresh or ID token. This is the backend-for-frontend (BFF) pattern. It is part of keenauth-python, which is not published to PyPI yet.
To protect an API that receives bearer tokens instead, see Protecting your API.
Install
pip install "keenauth-python[fastapi]"
Before you start
- In the dashboard, create a web application in your environment.
- Choose client authentication:
private_key_jwt(recommended,PrivateKeyJwt) or a client secret (ClientSecretBasic). - Add the redirect URI
https://app.acme.example/auth/callbackand the post-logout redirect URIhttps://app.acme.example/. - Generate a cookie secret of at least 32 characters, for example with
openssl rand -base64 32. Keep it in your secret store, not in the repository.
Create the instance
import os
from keenauth.bff import BffOptions, ClientSecretBasic, KeenAuthBff
bff = KeenAuthBff(BffOptions(
issuer="https://acme.eu.keenauth.app", # your environment's issuer, exactly
client_id="app_01j9zzzzzzzzzzzzzzzzzzzz",
client_auth=ClientSecretBasic(os.environ["KEENAUTH_CLIENT_SECRET"]),
app_base_url="https://app.acme.example",
secret=os.environ["KEENAUTH_COOKIE_SECRET"],
))
app_base_url is the public address of your app. The SDK builds the redirect URI and every redirect from it, never from the request's Host or X-Forwarded-* headers. Plain http is accepted only for localhost. The options are checked when KeenAuthBff is created, so a configuration error stops the app at start-up.
Use private_key_jwt
With private_key_jwt the application has no shared secret. Give PrivateKeyJwt your private key, and the SDK signs a new client assertion (RFC 7523) for every request to the token endpoint:
from keenauth.bff import PrivateKeyJwt
client_auth = PrivateKeyJwt(key=os.environ["KEENAUTH_PRIVATE_KEY"]) # unencrypted PEM
- Supported keys: EC P-256 (
ES256), EC P-384 (ES384), Ed25519 (EdDSA) and RSA of at least 2048 bits (PS256by default; passalgorithm="RS256"forRS256). - Each assertion has
issandsubset to your client ID,audset to the issuer, a randomjtiand a lifetime of 60 seconds. Itskidis the RFC 7638 thumbprint of the public key. - Register the public key on the application.
keenauth.m2m.load_private_key(pem).public_jwk()returns it as a JWK.
To sign with your own key service (a KMS or HSM), pass a function instead: PrivateKeyJwt(assertion=sign). sign() must return a new signed assertion on every call.
Install the middleware
from fastapi import Depends, FastAPI
from keenauth.bff import SessionContext
from keenauth.bff.fastapi import RequireSession, install
app = FastAPI()
install(app, bff)
@app.get("/orders")
def orders(ctx: SessionContext = Depends(RequireSession(bff))):
return {"email": ctx.session.user.email}
install serves the routes below and reads the session on every other request into request.state.keenauth. The SDK's network calls are synchronous; install runs them in Starlette's thread pool so they do not block the event loop.
Routes
| Route | What it does |
|---|---|
GET /auth/login |
Starts sign-in. Optional query parameters: returnTo (where the user lands) and organization (sign in to an organization). |
GET /auth/callback |
Finishes sign-in and sets the session cookie. |
GET or POST /auth/logout |
Signs out at KeenAuth and returns to your app. |
Sign-in uses the authorization code flow with PKCE (S256). The request is pushed to the server (PAR) when your environment offers it. On the callback the SDK checks state, iss and the ID token, including its nonce. Each successful sign-in creates a new session; the old one is discarded.
returnTo must be a path of your own app, such as /orders?tab=open. Any other value, such as //evil.example or a full URL, is ignored and the user lands on /.
Protected views
Add Depends(RequireSession(bff)) to a path operation. It returns the session context.
Without a session:
- a page request (
GETorHEAD) is redirected to/auth/loginwithreturnTo; - a request to
/api/…, or with another method, gets401with a problem document. Passapi=Trueto answer401on any path.
For POST, PUT, PATCH, DELETE and other unsafe methods, the browser must say the request comes from your app: Sec-Fetch-Site: same-origin, or an Origin header equal to your app's origin. Other requests get 403. This protects against cross-site request forgery in addition to SameSite=Lax.
Use the session and call your API
The session context has:
session:None, or the signed-in user.session.userhas typed ID token claims such assubject,email,name,org_idandorg_roles; all claims are insession.user.claims. The session object never contains a token.access_token: a valid access token for server-side calls, orNone. Do not send it to the browser.
When the access token expires within 60 seconds, the SDK refreshes it before your view runs. Each refresh rotates the refresh token and sets the new session cookie on the response. If the server refuses the refresh token, the session ends. A temporary server error keeps the session unchanged. Concurrent requests in one process share one refresh.
Switch organization
bff.switch_organization_path("org_01j9…", "/dashboard") returns a link to /auth/login that signs the user in again in that organization. KeenAuth checks the membership. After sign-in, session.user.org_id holds the organization.
Sign out
Send a POST to /auth/logout from a form. The SDK deletes the session cookie (and the stored session, if you use a store), revokes the refresh token and sends the browser to your environment's logout endpoint, which returns it to your app. A request that the browser marks as coming from another site is refused. Browsers that do not send Sec-Fetch-Site can sign out only with a POST from your own pages.
Session storage
By default the whole session is in the cookie, encrypted and authenticated with AES-256-GCM using a key derived from secret. A changed or copied cookie is rejected. The cookie is named __Host-keenauth_session.0 and set with Secure, HttpOnly, SameSite=Lax and Path=/. On http://localhost the __Host- prefix and Secure are left out. Large sessions are split into up to three cookies.
To keep tokens on the server, pass store= with an object that has get(id), set(id, session, ttl_seconds) and delete(id), for example backed by Redis. The cookie then holds only an encrypted random session ID. Every sign-in creates a new ID and deletes the previous record. The store holds tokens, so restrict access to it. MemorySessionStore is for development and tests only.
A session lasts at most 7 days (session_max_age); refreshing does not extend it. To change the secret, pass a list: secret=[new_secret, old_secret]. Remove the old one after session_max_age.
Current limits
- DPoP-bound tokens and back-channel logout are not handled yet.
- Refreshes are shared only within one server process. With several processes, the application setting
refresh_reuse_interval_s(default 5 seconds) lets a parallel refresh succeed; if you set it to0, the second refresh ends the session.