Overview
The Banno integration uses a two-layer authentication model:- Layer 1 — Banno OIDC + PKCE — external sign-in with Banno. Produces a Banno
access_token(for Consumer API calls) and anid_token(for member identity). - Layer 2 — Internal JWT — a short-lived HS256 token issued by the RAF service, stored in an httpOnly cookie, that gates every RAF view and API call after sign-in.
user_id. The client only ever sees the internal JWT cookie.
Layer 1 — Banno OIDC + PKCE
PKCE Pair Generation
On every auth start, the service generates a fresh PKCE pair and a CSRF state token:code_verifier and state are stored in the Django cache keyed by a session identifier and retrieved during callback validation.
Authorization Request
claims parameter:
| Claim | Purpose |
|---|---|
https://api.banno.com/consumer/claim/institution_id | FI identifier — used to pick the Tenant row |
https://api.banno.com/consumer/claim/tax_id | Member SSN — hashed into Member.ssn_hash for duplicate-person detection |
https://api.banno.com/consumer/claim/customer_identifier | Banno customer identifier |
Token Exchange
id_token Claims Consumed
| Claim | Stored As | Used For |
|---|---|---|
sub | Member.platform_member_id | Primary member key per tenant |
given_name, family_name | Member.first_name, Member.last_name | Display in plugin tile + emails |
https://api.banno.com/consumer/claim/institution_id | Tenant lookup | Resolve which FI this member belongs to |
https://api.banno.com/consumer/claim/tax_id | Member.ssn_hash (SHA-256) | Duplicate-person detection |
Server-Side Token Cache
Banno tokens are cached server-side, keyed byuser_id, with a TTL matching expires_in. The client never sees them:
Layer 2 — Internal JWT
After the token exchange, the RAF service issues its own internal JWT and drops it into an httpOnly cookie.Properties
| Property | Value |
|---|---|
| Algorithm | HS256 |
| Signing Key | Django SECRET_KEY |
| TTL | INTERNAL_JWT_LIFETIME_SECONDS (default 600s) |
| Cookie name | raf_access_token |
| Cookie flags | httpOnly, secure (prod), samesite=None (needed for iframe), path=/ |
Claims
| Claim | Description |
|---|---|
sub | platform_member_id (Banno sub) |
iat | Issued at (Unix timestamp) |
exp | Expires at (Unix timestamp) |
iss | "raf-service" |
type | "access" |
given_name | First name, for UI personalization |
provider | "banno" (or "q2" on Q2 deployments) |
Silent Refresh
The@cookie_jwt_required decorator transparently re-issues the internal JWT as long as the server-side Banno token cache still holds a valid access token:
- Internal JWT present and valid → proceed.
- Internal JWT expired and Banno cache still valid → re-issue internal JWT silently, set new cookie, proceed.
- Internal JWT expired and Banno cache empty/expired → redirect to
/auth/for full re-auth.
Callback Validation
JWKS signature verification for the Bannoid_tokenis planned; the current build decodes withoutverify_signature. Add JWKS validation before production rollout.
Bearer Token API
The JSON API (/api/...) accepts the same internal JWT as a Bearer token in the Authorization header, for clients that can’t use cookies (e.g. server-to-server or a native mobile harness). The helper _get_bearer_user_id_or_error(request) parses and validates the header.
| HTTP | Behavior |
|---|---|
| Missing / malformed header | 401 UNAUTHORIZED |
| JWT expired | 401 — client must re-auth (no silent refresh on API path) |
| JWT valid but Banno token cache empty | 401 — session expired |
| JWT valid and Banno cache present | Request proceeds |
Security Controls
| Control | Where |
|---|---|
| PKCE (RFC 7636) | InviteFriend/banno_oauth.py |
| State token CSRF | banno_oauth.py + callback |
| Server-side token cache | views.py — tokens never exposed to the browser |
| HTTPOnly internal JWT cookie | views.py |
| SSN hashing (SHA-256) | referral_code_service.py — raw SSN never persisted |
frame-ancestors CSP | banno_raf/middleware.py — restricts iframe embedding to BANNO_FI_URL |
| No self-referral | Enforced in referral_service.py before creating a Referral |
