Purpose and release status
This document is the security and test contract for signing the same Band Licence account into the iPhone, iPad and Android clients.
Current status: server and unsigned client foundation implemented; production handoff disabled.
The repository currently contains:
- a versioned public configuration endpoint;
- an account-authorised, two-minute handoff;
- mandatory S256 PKCE and exact state comparison;
- an atomic, single-use code exchange;
- hashed handoff codes and hashed server sessions;
- durable rate limiting and account audit records;
- a revocable 12-hour native session;
- system-browser sign-in in SwiftUI and Android;
- device-only Apple Keychain storage and Android Keystore-backed AES-GCM storage; and
- fail-closed Apple Universal Link and Android App Link endpoints.
This is not evidence that the production hostname, signing identities, association files, release builds or real-device tests are complete. Native sign-in must remain disabled until all release gates in this document have real evidence.
This is a purpose-built first-party handoff, not a claim that Band Licence is a general OAuth authorisation server. Its design follows the relevant native-app security properties from IETF best current practice.
Source-of-truth implementation
| Concern | Implemented location | Current behaviour |
|---|---|---|
| Public bootstrap | app/api/client/v1/config/route.ts | Contract version 2; no account, school or student data; no-store response |
| Browser authorisation | app/native/sign-in/actions.ts | Requires an existing secure web account session and explicit confirmation |
| Handoff creation and exchange | lib/native-sign-in.ts | 120-second code, S256 PKCE, atomic claim, hashed code and audit |
| Exchange API | app/api/client/v1/auth/exchange/route.ts | 4 KiB request limit, client/version binding, durable rate limit and generic errors |
| Native session API | app/api/client/v1/session/route.ts | Account and school-role bootstrap; DELETE revokes the presented native token |
| Verified-link policy | lib/mobile-associations.ts | Returns not-ready until host, mode, cookies and signing identities all validate |
| Apple client | mobile/ios/BandLicence | ASWebAuthenticationSession, exact callback checks and Keychain |
| Android client | mobile/android/app | Custom Tabs, exact callback checks and Keystore-backed encryption |
| Static checks | scripts/native-foundation-check.mjs | Confirms the repository foundation only |
| Release checks | scripts/mobile-readiness-check.mjs | Separates development warnings from blocking release failures |
Trust boundaries
- The native app is a public client. It cannot safely hold a client secret.
- The system browser owns password entry and the existing web session. The native app must never render the password form inside an app-controlled WebView.
- The callback carries only a short-lived code and state. It never carries a password or lasting session token.
- The app instance proves possession of the verifier that produced the S256 challenge.
- The server remains authoritative for account state, email verification, school roles, session expiry and revocation.
- Apple and Android verified links prove that the production domain has delegated the callback path to the signed app.
Required preconditions
The configuration endpoint must report nativeSignIn.enabled as true only when all of the following are true:
- BAND_LICENCE_DEPLOYMENT_MODE is account-beta or public-production;
- BAND_LICENCE_APP_URL is one stable root HTTPS origin;
- a separate normal browser trusts the certificate without a warning;
- BAND_LICENCE_TRUSTED_HTTPS is true only after that test;
- BAND_LICENCE_SECURE_COOKIES is true;
- BAND_LICENCE_NATIVE_LINKS_ENABLED is true;
- the final Apple Team ID and bundle identifier are configured;
- the final Android package identifier is configured;
- the installed Android signing certificate SHA-256 fingerprint is configured; and
- both association endpoints return the exact approved payload without a redirect.
If any precondition becomes false, the association endpoints return 404 and the exchange returns a not-ready response. That is the immediate server-side kill switch.
Protocol contract
1. Discover the contract
The client fetches:
GET /api/client/v1/configThe client must:
- require contractVersion 2;
- require authentication.nativeSignIn.enabled to be true;
- require S256 in codeChallengeMethods;
- use the advertised relative authorisation and exchange paths;
- reject a configured origin that is not the exact approved HTTPS origin; and
- stop with a plain-language error when the contract version is unsupported.
The config response is deliberately non-sensitive. It must not be used as proof that a signed build or store release has been approved.
2. Create request-bound secrets
For each attempt, the app generates:
- 32 cryptographically random bytes encoded as unpadded base64url for the PKCE verifier;
- a separate random state value;
- BASE64URL(SHA256(verifier)) as the challenge; and
- the app version/build identifier.
The verifier and state stay in memory only for the active attempt. If the app restarts, the operating system kills it or the pending values are lost, the callback must fail safely and the user must start again.
Do not reuse a verifier or state. Do not write either value to logs, analytics, crash reports, pasteboards, screenshots, browser storage or backups.
3. Open the external browser
The app opens the advertised authorisation endpoint with:
client=ios
app_version=1.0.0+1
code_challenge=CHALLENGE
code_challenge_method=S256
state=STATEAndroid uses client=android. Apple uses ASWebAuthenticationSession. Android uses Custom Tabs. An embedded password WebView is prohibited.
The website:
- validates the complete request;
- sends a signed-out user through normal secure account sign-in;
- requires the user to confirm the native handoff;
- rejects disabled or unverified accounts;
- rate-limits creation attempts; and
- redirects to the fixed verified HTTPS callback.
4. Validate the callback before exchange
The only accepted callback is:
https://APPROVED_HOST/native/sign-in/completeBefore reading the code, the app must confirm:
- scheme is HTTPS;
- host exactly equals the configured host;
- path exactly equals /native/sign-in/complete;
- returned state exactly equals the in-memory pending state; and
- a pending verifier exists.
Query fragments, lookalike domains, Unicode host ambiguity, subdomains, non-default origins and alternate callback paths must not be accepted.
On any mismatch, clear all pending state, display a non-technical failure and start no network exchange.
5. Exchange once
The app immediately sends:
POST /api/client/v1/auth/exchange
Content-Type: application/json
X-Band-Licence-Client: ios
X-Band-Licence-App-Version: 1.0.0+1
{"code":"RETURNED_CODE","codeVerifier":"ORIGINAL_VERIFIER"}The server verifies the code hash, expiry, unused status, client kind, exact app version, account state and PKCE challenge inside an immediate database transaction. Successful exchange marks the code used before creating the session.
The response contains the session token, expiry, account summary and accessible schools. The app stores only the session token, then clears the code, verifier and state.
All rejected grants deliberately become the same public invalid_grant error. The app must not reveal whether a code existed, expired, belonged to another platform or failed PKCE.
6. Use and revoke the session
The app sends:
Authorization: Bearer SESSION_TOKENGET /api/client/v1/session returns the account and accessible school roles. DELETE /api/client/v1/session revokes the presented native session.
The current maximum session lifetime is 12 hours. Expiry is controlled by the server, not the device clock. A session must also stop working after explicit device revocation, account disablement or other server-side revocation.
The client must:
- treat 401 as signed out, remove the local token and offer sign-in;
- never retry a rejected session indefinitely;
- never place the token in a URL;
- never copy the token to browser cookies;
- never use a token issued to the other platform; and
- make sign-out locally complete even if the revocation request cannot reach the server, while explaining that the user should revoke the device from the web account when connectivity returns.
Error and retry policy
| Condition | Client action |
|---|---|
| User cancels system browser | Clear pending values; remain signed out; no error loop |
| Invalid callback, host or state | Clear pending values; do not exchange; start a new attempt only on user action |
| invalid_grant | Clear code and verifier; never retry that code |
| 429 temporarily_unavailable | Respect Retry-After; disable rapid retry; do not expose rate-limit identifiers |
| 401 session response | Delete local token and return to signed-out state |
| Offline or timeout | Preserve an already stored session, show offline state, and retry only a safe GET |
| Contract version mismatch | Block sign-in and direct the user to update the app |
| Native sign-in disabled | Explain that the host is not approved; do not fall back to copyable tokens |
| Verified link opens in browser | Show the safe browser completion page; do not reveal a reusable credential |
POST exchange is not a general retryable operation. A client may retry only when it can establish that no HTTP response was received, and even then it must handle invalid_grant as final because the first request may have succeeded.
Threat controls and residual risks
| Threat | Present control | Evidence still required |
|---|---|---|
| Password theft by app | System browser; no embedded password WebView | Runtime inspection on signed builds |
| Stolen callback code | Two-minute expiry, S256 PKCE and single use | Proxy/network test on real devices |
| Callback injection or CSRF | Random state, exact state match and PKCE binding | Negative deep-link tests |
| Link hijacking | Claimed HTTPS callback and fail-closed association files | Universal Link/App Link verification on store-signed builds |
| Token theft at rest | Device-only Keychain; Android Keystore-backed AES-GCM | Locked-device, backup and extraction tests |
| Token leakage in logs | No intended logging of credentials | Device logs, crash reports and support export inspection |
| Brute-force exchange | 43-character random code and durable rate limiting | Production alert and Retry-After test |
| Concurrent code reuse | Immediate transaction and conditional claim | Concurrency test against production-like SQLite |
| Revoked role or account | Server-authoritative session and school-role lookup | End-to-end role removal and account disablement tests |
| Malicious/outdated client | Platform and version binding; contract version gate | Minimum-version policy is not implemented yet |
| Compromised device | Revocable short session | No device attestation decision yet; record as a later risk decision |
No design can promise safety on a fully compromised device. Device attestation or integrity APIs must not become a silent requirement for children or school devices without an accessibility, privacy and support assessment.
Physical-device sign-in matrix
Run every row on a real iPhone, iPad, Android phone and Android tablet. Repeat the verified-link rows with the exact release-signed candidate, not a debug certificate.
| Scenario | Expected result | Evidence |
|---|---|---|
| Fresh successful sign-in | Correct account and schools; one device session listed | Screen recording with fictional data and audit reference |
| Existing browser web session | User sees confirmation, not a silent background grant | Recording |
| Browser requires password | Password remains in system browser only | Runtime inspection |
| User cancels | App remains signed out; pending state cleared | Test record |
| Wrong state | No exchange request; plain failure | Network capture and test record |
| Wrong verifier | Generic invalid_grant; no session | Server audit reference |
| Wrong platform/version | Generic invalid_grant | Server audit reference |
| Code reuse | Generic invalid_grant; only one session exists | Database/audit reference without secrets |
| Code after 121 seconds | Generic invalid_grant | Timestamped test |
| App killed before callback | Callback cannot complete; fresh attempt succeeds | Test record |
| Two simultaneous callbacks | At most one exchange succeeds | Concurrency record |
| Offline during callback | No token exposed; safe restart | Recording |
| Sign out | Token removed locally and revoked remotely | Device list before/after |
| Web device revocation | Next authenticated request signs app out | Recording |
| Account disabled | Existing and new access blocked as designed | Audit reference |
| School role removed | Removed school is unavailable after refresh | Cross-device record |
| Camera denied | Sign-in remains usable; scanner explains recovery | Accessibility test |
| Universal/App Link not verified | Safe browser page; no copy-token fallback | Signed-build record |
Privacy, accessibility and child-safety requirements
- Native sign-in is for authorised account holders. It does not create open student or parent/carer registration.
- Do not expose student names, schools, roles or account email on the callback URL, browser history or lock-screen notifications.
- Keep all examples and review accounts fictional.
- Sign-in, cancellation, error recovery, account selection, school selection and sign-out must be operable with VoiceOver, Voice Control, TalkBack, Switch Access, large text and an external keyboard.
- Status must never depend on colour alone. Focus must return predictably after the system browser closes.
- Do not add a microphone, photo library, location, contacts or tracking permission to sign-in.
- Do not add an advertising identifier or behavioural analytics.
- School and child-safety approval must cover the native account audience before students or families receive accounts.
Operational response and rollback
If suspicious exchanges, callback hijacking, token leakage or association-file errors are detected:
- Set BAND_LICENCE_NATIVE_LINKS_ENABLED to false and restart the service.
- Confirm both association endpoints now fail closed and config reports native sign-in disabled.
- Revoke affected native sessions from the server.
- Preserve audit and security-event records without copying credentials.
- Notify the incident owner and follow the incident-response process.
- Correct the host or signed client and repeat every negative sign-in test.
- Re-enable only after a new release owner approval.
Removing or rolling back a mobile binary does not revoke sessions by itself. Server-side revocation is required.
Evidence record
For each release candidate retain privately:
- app version, build number, commit SHA and build archive checksum;
- Apple bundle ID, Team ID, signing certificate and provisioning profile references;
- Android application ID, version code, Play App Signing certificate fingerprint and upload artifact checksum;
- final HTTPS origin and certificate test;
- association endpoint payload checks and physical-device verification;
- all matrix results with device, OS, tester, date and issue links;
- server audit references for success, rejection, revocation and rate limiting;
- accessibility results for the complete sign-in path;
- review of device logs, crash reports and backups for secret leakage; and
- release owner, privacy owner and security reviewer sign-off.
Store references, not credentials, in the evidence record. Keep signing keys, session tokens, codes, passwords and real student data outside the repository.
Decision records
NSH-001 — External browser only
Decision: Use ASWebAuthenticationSession and Android Custom Tabs. Do not use an embedded password WebView.
Reason: It separates the app from credentials and follows IETF native-app best current practice.
NSH-002 — Claimed HTTPS callback
Decision: Use the final HTTPS origin and /native/sign-in/complete through Universal Links and App Links.
Reason: The operating system can associate the domain with the signed app, and the callback has a safe browser fallback.
NSH-003 — S256 PKCE plus state
Decision: Require S256 with no downgrade and also compare random state.
Reason: PKCE binds the code to the initiating app instance; state supplies an explicit correlation check and defence in depth.
NSH-004 — Short handoff, revocable session
Decision: Use a 120-second single-use code and a 12-hour server session.
Reason: The callback credential has minimal value and devices remain individually revocable.
NSH-005 — No offline account database
Decision: Store only the protected session token on device.
Reason: The server remains the permission and school-data authority. Offline school-data synchronisation requires a separate approved threat model.
Related guides
Official references
Checked 12 August 2026. Recheck before every native release:
- IETF RFC 8252: OAuth 2.0 for Native Apps
- IETF RFC 7636: Proof Key for Code Exchange
- IETF RFC 9700: Best Current Practice for OAuth 2.0 Security
- Apple: Authenticating a user through a web service
- Apple: Supporting Associated Domains
- Android: Configure App Links
- Android: Verify App Links
- OWASP Mobile Application Security Verification Standard