feat(auth): native SSO authorization bridge for the Android app
Add a Mobile SSO Authorization Bridge so the native app can "Sign in with Google/Discord" without shipping any OAuth secret. It EXTENDS the existing /auth/sso/* redirect flow (same PKCE-vs-IdP, link-only + opt-in provisioning, TOTP gate) and terminates in the existing mobile bearer tokens — not a parallel auth path. - Schema: mobile_auth_sessions + mobile_auth_codes (short-lived, self-pruning; authorization code stored hash-only, PKCE challenge is a hash by construction). - GET /auth/mobile/sso/start: validate provider enabled + redirect_uri by EXACT allowlist match (never prefix), seed a bridge session, reuse the SSO redirect tagged mode:'mobile' (new redirectToIdp helper extracted from beginFlow). - SSO callback + finishSsoTotp gain a mode:'mobile' branch: mint a single-use, hashed, PKCE-bound code and redirect to the fixed app callback (code + echoed state, never a token) instead of setting a cookie. 2FA keeps full parity via the existing web TOTP form (now carrying the bridge session). - POST /auth/mobile/sso/exchange: verify Layer-B PKCE (before burning the code), single-use consume, then issue the SAME pair as /auth/mobile/login. - Discovery reuses GET /auth/providers; refresh/logout reuse /auth/mobile/*. - Rate limits: /start per-IP+provider, /exchange per-IP. Boot-time + opportunistic prune of both tables (no cron, mirrors revoked_sessions). - Redirect allowlist is MOBILE_AUTH_REDIRECT_URIS (default the one fixed runicgateway://auth/callback); App Link URIs can be appended per shard later. - Swagger regenerated; 39 tests (model single-use/gating + full controller matrix: bad/expired/reused code, PKCE mismatch, disabled provider, redirect allowlist, TOTP-through-bridge). Full suite green (271). Refs docs/website/BACKEND_DESIGN.md, docs/android/PLAN.md §9 (M9). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -894,6 +894,142 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/mobile/sso/start": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth · Mobile"
|
||||
],
|
||||
"summary": "Begin native SSO login (redirect to the IdP)",
|
||||
"description": "Opened by the Android app in a Custom Tab. Validates the provider is enabled and the redirect_uri is an exact match of a registered app callback, seeds a short-lived bridge session carrying the app PKCE challenge + state, and 302-redirects into the existing website SSO flow. On success the callback redirects to `redirect_uri?code=…&state=…` (a one-time code, never a token). Errors are surfaced to the app as `redirect_uri?error=…&state=…`.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Provider id from GET /auth/providers (e.g. google, discord)."
|
||||
},
|
||||
{
|
||||
"name": "code_challenge",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "App-generated PKCE S256 challenge (base64url)."
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "App-generated opaque CSRF value, echoed on the callback for the app to verify."
|
||||
},
|
||||
{
|
||||
"name": "redirect_uri",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The app callback; must EXACTLY match a registered value (default runicgateway://auth/callback)."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"302": {
|
||||
"description": "Redirect to the identity provider (or back to the app callback on error)"
|
||||
},
|
||||
"400": {
|
||||
"description": "Unrecognized redirect URI or validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many attempts (rate limited)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/mobile/sso/exchange": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth · Mobile"
|
||||
],
|
||||
"summary": "Exchange an SSO authorization code for mobile tokens",
|
||||
"description": "Redeems the single-use authorization code returned to the app callback, together with the PKCE code_verifier, for the SAME access + refresh pair as /auth/mobile/login. The code is single-use and PKCE-bound: a wrong verifier, an expired/used code, or a reused code all fail 401.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Access + refresh tokens",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MobileTokenResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid/expired/used code or failed PKCE verification",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many attempts (rate limited)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MobileSsoExchangeRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/providers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1096,6 +1232,9 @@
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict"
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many attempts (rate limited / backoff)",
|
||||
"content": {
|
||||
@@ -11115,6 +11254,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MobileSsoExchangeRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"code",
|
||||
"code_verifier"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The single-use authorization code returned to the app callback."
|
||||
}
|
||||
}
|
||||
},
|
||||
"code_verifier": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The PKCE verifier for the challenge sent to /auth/mobile/sso/start."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Message": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user