> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nuevesolutions.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication Flow

## 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:

```python theme={null}
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:

| 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

```
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:**

```json theme={null}
{
  "access_token": "eyJ...",
  "id_token":     "eyJ...",
  "expires_in":   3600,
  "token_type":   "Bearer"
}
```

### 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 by `user_id`, with a TTL matching `expires_in`. The client never sees them:

```python theme={null}
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

| 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.

This keeps the iframe session alive during normal Banno dashboard use without bouncing members back through OIDC every 10 minutes.

***

## Callback Validation

```python theme={null}
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.

| 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`           |
