Skip to main content

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 an id_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.
Banno tokens are never sent to the browser. They live in a server-side cache (Django cache) keyed by user_id. The client only ever sees the internal JWT cookie.
Banno dashboard (iframe)


GET /plugin/                               ← anonymous plugin tile
  │  member clicks "Invite Now" / "Submit Code"

GET /auth/                                 ← build PKCE pair + state
  │  302 → Banno OIDC auth endpoint

Banno sign-in + consent


GET /oidc/callback/?code=...&state=...     ← validate state, exchange code
  │  Banno returns {access_token, id_token}
  │  cache Banno tokens server-side
  │  issue internal JWT → Set-Cookie raf_access_token
  │  302 → /dashboard/

GET /dashboard/                            ← live eligibility check → render

Layer 1 — Banno OIDC + PKCE

PKCE Pair Generation

On every auth start, the service generates a fresh PKCE pair and a CSRF state token:
def create_code_verifier(length=128):
    return secrets.token_urlsafe(96)[:length]

def create_code_challenge(code_verifier):
    digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

def create_state():
    return secrets.token_hex(60)[:128]
The code_verifier and state are stored in the Django cache keyed by a session identifier and retrieved during callback validation.

Authorization Request

GET https://{FI_URL}/a/consumer/api/v0/oidc/auth
  ?scope=openid profile https://api.banno.com/consumer/auth/accounts.readonly
  &response_type=code
  &client_id={CLIENT_ID}
  &redirect_uri={REDIRECT_URI}
  &state={STATE}
  &code_challenge={CODE_CHALLENGE}
  &code_challenge_method=S256
  &claims={...}
Additional claims requested in the claims parameter:
ClaimPurpose
https://api.banno.com/consumer/claim/institution_idFI identifier — used to pick the Tenant row
https://api.banno.com/consumer/claim/tax_idMember SSN — hashed into Member.ssn_hash for duplicate-person detection
https://api.banno.com/consumer/claim/customer_identifierBanno customer identifier

Token Exchange

POST https://{FI_URL}/a/consumer/api/v0/oidc/token
Content-Type: application/x-www-form-urlencoded

client_id={CLIENT_ID}
&client_secret={CLIENT_SECRET}
&grant_type=authorization_code
&code={AUTH_CODE}
&redirect_uri={REDIRECT_URI}
&code_verifier={CODE_VERIFIER}
Response:
{
  "access_token": "eyJ...",
  "id_token":     "eyJ...",
  "expires_in":   3600,
  "token_type":   "Bearer"
}

id_token Claims Consumed

ClaimStored AsUsed For
subMember.platform_member_idPrimary member key per tenant
given_name, family_nameMember.first_name, Member.last_nameDisplay in plugin tile + emails
https://api.banno.com/consumer/claim/institution_idTenant lookupResolve which FI this member belongs to
https://api.banno.com/consumer/claim/tax_idMember.ssn_hash (SHA-256)Duplicate-person detection

Server-Side Token Cache

Banno tokens are cached server-side, keyed by user_id, with a TTL matching expires_in. The client never sees them:
cache.set(
    _get_token_cache_key(user_id),
    {"access_token": access_token, "expires_at": now + expires_in},
    timeout=expires_in,
)

Layer 2 — Internal JWT

After the token exchange, the RAF service issues its own internal JWT and drops it into an httpOnly cookie.

Properties

PropertyValue
AlgorithmHS256
Signing KeyDjango SECRET_KEY
TTLINTERNAL_JWT_LIFETIME_SECONDS (default 600s)
Cookie nameraf_access_token
Cookie flagshttpOnly, secure (prod), samesite=None (needed for iframe), path=/

Claims

ClaimDescription
subplatform_member_id (Banno sub)
iatIssued at (Unix timestamp)
expExpires at (Unix timestamp)
iss"raf-service"
type"access"
given_nameFirst 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.
This keeps the iframe session alive during normal Banno dashboard use without bouncing members back through OIDC every 10 minutes.

Callback Validation

def oidc_callback(request):
    returned_state = request.GET.get("state")
    expected_state = cache.get(_get_state_cache_key(session_id))
    if not expected_state or returned_state != expected_state:
        return redirect("auth_login")        # CSRF — fail closed

    code = request.GET.get("code")
    verifier = cache.get(_get_verifier_cache_key(session_id))
    tokens = exchange_code_for_tokens(code, redirect_uri, verifier)

    claims = jwt_decode(tokens["id_token"], options={"verify_signature": False})
    user_id = claims["sub"]
    tenant = resolve_tenant(claims["https://api.banno.com/consumer/claim/institution_id"])
    member = get_or_create_member(tenant, user_id, claims)

    cache.set(_get_token_cache_key(user_id), tokens, timeout=tokens["expires_in"])
    internal_jwt = generate_internal_token(user_id, claims)

    response = redirect("dashboard")
    response.set_cookie(
        INTERNAL_JWT_COOKIE_NAME,
        internal_jwt,
        max_age=INTERNAL_JWT_COOKIE_MAX_AGE,
        httponly=True,
        secure=INTERNAL_JWT_COOKIE_SECURE,
        samesite=INTERNAL_JWT_COOKIE_SAMESITE,
    )
    return response
JWKS signature verification for the Banno id_token is planned; the current build decodes without verify_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.
HTTPBehavior
Missing / malformed header401 UNAUTHORIZED
JWT expired401 — client must re-auth (no silent refresh on API path)
JWT valid but Banno token cache empty401 — session expired
JWT valid and Banno cache presentRequest proceeds

Security Controls

ControlWhere
PKCE (RFC 7636)InviteFriend/banno_oauth.py
State token CSRFbanno_oauth.py + callback
Server-side token cacheviews.py — tokens never exposed to the browser
HTTPOnly internal JWT cookieviews.py
SSN hashing (SHA-256)referral_code_service.py — raw SSN never persisted
frame-ancestors CSPbanno_raf/middleware.py — restricts iframe embedding to BANNO_FI_URL
No self-referralEnforced in referral_service.py before creating a Referral