Files
OSIT-AE-API-FastAPI/documentation/PROJECT__AE_Site_Passcode_Security.md
2026-04-10 11:56:44 -04:00

16 KiB
Raw Blame History

PROJECT: Site Passcode Security — API-Verified Auth

Last updated: 2026-04-10 Status: Backend work in progress — frontend pending backend completion Priority: High — passcodes for trusted/administrator access currently in localStorage plaintext


Problem Statement

When a user loads the Aether frontend, the site bootstrap response includes access_code_kv_json — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in $ae_loc.site_access_code_kv, which is persisted in localStorage.

Result: Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.

The passcode check (handle_check_access_type_passcode in e_app_access_type.svelte) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a /authenticate_passcode endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.

Source of Truth

site.access_code_kv_json is the single source of truth for all passcodes. The v_site_domain DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and /authenticate_passcode read from the same data.


Threat Model

Threat Current After Fix
Attacker inspects localStorage Sees all passcodes in plaintext Sees a JWT (opaque, no passcode)
Attacker uses stolen trusted passcode Trivial if they have localStorage access Still possible if they enter the passcode — unavoidable
Attacker replays an old passcode after it changes Works forever (cached value never refreshes) Fails — API verifies against current DB value
Attacker tampers with access_type in localStorage Grants apparent permission but API calls still fail Same — access_type is still persisted separately
Passcode reuse across sessions Works indefinitely JWT TTL enforces session expiry per role
Offline / API-unavailable entry Works (local cache) Blocked — requires API to verify

The fundamental constraint

Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by x-aether-api-key + x-account-id. The passcode primarily controls what the frontend shows and some API-level permission gates for trusted routes.


Proposed Solution: API-Verified Passcode + JWT Session

Core idea

  1. Never send passcodes to the client. The frontend stops reading/storing access_code_kv_json from the bootstrap response.
  2. Passcode entry triggers an API call to /authenticate_passcode. API verifies server-side against the DB.
  3. On success, the API returns a JWT — the JWT contains the role, account context, and expiry.
  4. Store the JWT in $ae_loc.jwt (already a field, already wired into $ae_api).
  5. On page reload, check the JWT's eat (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, access_type is already persisted in $ae_loc.

Session restore on reload

  • access_type still persists in localStorage (no change here)
  • The JWT is the proof that the access was legitimately granted and is still valid
  • On page load: decode JWT payload (base64 the middle segment), check eat vs Date.now()/1000
  • If JWT expired → reset access_type to anonymous, clear JWT
  • If JWT valid → no action needed, access_type is already correct

This gives session expiry without a network call on every page load.


TTL Per Role — Decided

Access Level JWT TTL Notes
super 8 hours Highest privilege
manager 24 hours
administrator 48 hours
trusted 48 hours Onsite staff — covers multi-day events
public 24 hours
authenticated 12 hours
anonymous N/A No passcode

Caching Decision

No passcode caching. Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50150ms), which is acceptable for a once-per-session action.


Backend Changes Required

Note: The backend fixes described below have been implemented and tested in the aether_api_fastapi repository (the /authenticate_passcode endpoint now uses explicit role priority, returns a full passcode JWT with auth_type: 'passcode', applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.

Phase 2 status: Not started — removing access_code_kv_json from the public site model remains pending.

File: aether_api_fastapi/app/routers/api.py

The /authenticate_passcode endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.

Fix 1: Passcode matching must use explicit priority order

Current (wrong):

for role, code in access_codes.items():  # dict insertion order — not guaranteed
    if str(code) == str(passcode):
        matched_role = role
        break

Required:

ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']

matched_role = None
for role in ROLE_PRIORITY:
    code = access_codes.get(role)
    if code and str(code) == str(passcode):
        matched_role = role
        break

This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.

Fix 2: JWT payload must include all six role flags

Current (incomplete):

payload = {
    'account_id': account_id_random,
    'administrator': (matched_role == 'administrator'),
    'manager':       (matched_role == 'manager'),
    'super':         (matched_role == 'super'),
    # trusted / public / authenticated missing
    ...
}

Required:

payload = {
    'account_id':    account_id_random,
    'super':         (matched_role == 'super'),
    'manager':       (matched_role == 'manager'),
    'administrator': (matched_role == 'administrator'),
    'trusted':       (matched_role == 'trusted'),
    'public':        (matched_role == 'public'),
    'authenticated': (matched_role == 'authenticated'),
    'json_str': json.dumps({
        'auth_type': 'passcode',   # distinguishes from user login JWTs
        'site_id':   site_id,
        'role':      matched_role  # canonical role string — frontend uses this
    })
}

The auth_type: 'passcode' marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.

Fix 3: Per-role TTL

Current:

token = sign_jwt(
    secret_key=settings.JWT_KEY,
    ttl=3600 * 24,  # hardcoded 24h for all roles
    **payload
)

Required:

ROLE_TTL = {
    'super':         8  * 3600,   #  8 hours
    'manager':       24 * 3600,   # 24 hours
    'administrator': 48 * 3600,   # 48 hours
    'trusted':       48 * 3600,   # 48 hours
    'public':        24 * 3600,   # 24 hours
    'authenticated': 12 * 3600,   # 12 hours
}

token = sign_jwt(
    secret_key=settings.JWT_KEY,
    ttl=ROLE_TTL[matched_role],
    **payload
)

Fix 4: Add minimum length validation to passcode field

Current:

passcode: str = Field(..., description="The passcode to verify")

Required:

passcode: str = Field(..., min_length=5, description="The passcode to verify")

This matches the frontend's 5-character trigger and prevents empty/trivial submissions.

Complete corrected endpoint (for reference)

ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']

ROLE_TTL = {
    'super':         8  * 3600,
    'manager':       24 * 3600,
    'administrator': 48 * 3600,
    'trusted':       48 * 3600,
    'public':        24 * 3600,
    'authenticated': 12 * 3600,
}

class PasscodeAuthRequest(BaseModel):
    """Request model for site-based passcode authentication."""
    site_id:  str = Field(..., description="Random string ID of the site")
    passcode: str = Field(..., min_length=5, description="The passcode to verify")

@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
        auth_req: PasscodeAuthRequest,
        response: Response = Response,
        ):
    """
    Passcode-to-JWT Endpoint.
    Verifies a passcode against site.access_code_kv_json (single source of truth —
    v_site_domain joins from the same site record).
    Returns a signed JWT with the site's account context, full role flags, and
    a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
    this token from a user login JWT.
    """
    site_id  = auth_req.site_id
    passcode = auth_req.passcode

    # 1. Look up the site record
    search_data = {'id_random': site_id}
    if record := sql_select(table_name='site', data=search_data):
        # 2. Parse access codes
        access_codes_raw = record.get('access_code_kv_json')
        access_codes = {}
        if access_codes_raw:
            try:
                access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
            except Exception as e:
                log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")

        # 3. Verify passcode in explicit priority order (highest privilege wins)
        matched_role = None
        for role in ROLE_PRIORITY:
            code = access_codes.get(role)
            if code and str(code) == str(passcode):
                matched_role = role
                break

        if matched_role:
            log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")

            # 4. Resolve account context
            account_id_random = record.get('account_id_random')
            if not account_id_random:
                if account_id_int := record.get('account_id'):
                    account_id_random = get_id_random(record_id=account_id_int, table_name='account')

            # 5. Mint JWT with complete role flags and per-role TTL
            payload = {
                'account_id':    account_id_random,
                'super':         (matched_role == 'super'),
                'manager':       (matched_role == 'manager'),
                'administrator': (matched_role == 'administrator'),
                'trusted':       (matched_role == 'trusted'),
                'public':        (matched_role == 'public'),
                'authenticated': (matched_role == 'authenticated'),
                'json_str': json.dumps({
                    'auth_type': 'passcode',
                    'site_id':   site_id,
                    'role':      matched_role
                })
            }

            token = sign_jwt(
                secret_key=settings.JWT_KEY,
                ttl=ROLE_TTL[matched_role],
                **payload
            )

            return mk_resp(
                data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
                response=response
            )
        else:
            log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
            return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
    else:
        log.warning(f"Auth Failed: Site {site_id} not found.")
        return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")

Backend Phase 2 (follow-up — not blocking frontend)

Remove access_code_kv_json from the Site_Domain_Base response model (site_domain_models.py). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on access_code_kv_json being in the base response before making this change.


Frontend Changes Required

These depend on the backend fixes above being deployed first.

1a. src/lib/app_components/e_app_access_type.svelte

Replace handle_check_access_type_passcode entirely. The new version:

  • Is async
  • Adds auth_pending: boolean = $state(false) and auth_error: string | null = $state(null)
  • Uses a direct fetch call (NOT post_object — avoids triggering the session-expired banner on a 401)
  • On success: sets $ae_loc.access_type = data.role, stores $ae_loc.jwt = data.jwt, triggers process_permission_check as before
  • On 401: shows inline error, clears entered_passcode, resets checked_passcode = null to allow retry
  • On network error: shows inline connection error
  • Clears auth_error when entered_passcode changes

API call shape:

POST /authenticate_passcode
Content-Type: application/json
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }

Add to template (near the passcode input):

{#if auth_pending}
    <Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
    <span class="text-error-500 text-xs">{auth_error}</span>
{/if}

1b. src/routes/+layout.ts

Stop caching passcodes from bootstrap — remove line ~394:

// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};

Add passcode JWT expiry check — after the block around line 84 where ae_loc_json.jwt is read, add:

// Enforce passcode JWT TTL on page load.
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
// User login JWTs (auth_type !== 'passcode') are left untouched.
if (ae_loc_json?.jwt) {
    try {
        const parts = ae_loc_json.jwt.split('.');
        if (parts.length === 3) {
            const jwt_payload = JSON.parse(atob(parts[1]));
            const json_str = typeof jwt_payload.json_str === 'string'
                ? JSON.parse(jwt_payload.json_str)
                : jwt_payload.json_str;
            if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
                // Passcode JWT has expired — revoke access
                ae_loc_json.jwt = null;
                ae_loc_json.access_type = 'anonymous';
            }
        }
    } catch {
        // Malformed JWT — leave untouched, let existing handling deal with it
    }
}

1c. src/lib/stores/ae_stores__auth_loc_defaults.ts (cleanup)

Remove site_access_code_kv from the AuthLocState interface and the auth_loc_defaults object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only e_app_access_type.svelte uses it — confirmed).


Migration Notes

  • Users with existing localStorage will still have site_access_code_kv cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
  • Existing persisted access_type is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
  • The $ae_loc.jwt field is already used by the user login flow. The auth_type: 'passcode' marker in json_str ensures the expiry logic only targets passcode sessions, not user login sessions.

Files Affected

File Repo Change
app/routers/api.py aether_api_fastapi Backend — do first. Priority ordering, full JWT payload, per-role TTL, min_length on passcode
app/models/site_domain_models.py aether_api_fastapi Phase 2: remove access_code_kv_json from public model
src/lib/app_components/e_app_access_type.svelte aether_app_sveltekit Replace local check with async API call; loading/error UI
src/routes/+layout.ts aether_app_sveltekit Stop caching passcodes; add JWT expiry check
src/lib/stores/ae_stores__auth_loc_defaults.ts aether_app_sveltekit Cleanup: remove site_access_code_kv
documentation/AE__Permissions_and_Security.md aether_app_sveltekit Update passcode auth section to reflect new flow