Silverpine SSO Documentation
https://sso.spsw.devInteractive API Reference
The Swagger UI site renders this service's OpenAPI document for the OAuth/OIDC, mobile handoff, password reset, and admin automation APIs.
Current Surface
The service exposes a health endpoint, protected admin login, app management, app-scoped user management, hosted passwordless and username/password login, OAuth authorization-code and client-credentials exchange, OIDC discovery/JWKS/userinfo, session introspection/revocation, and audit visibility.
GET /health
GET /.well-known/openid-configuration
GET /jwks.json
GET /authorize
POST /authorize
POST /mfa/challenge
GET /password-reset/request
POST /password-reset/request
GET /password-reset/{token}
POST /password-reset/{token}
GET /mobile/authorize
POST /mobile/authorize
POST /mobile/complete
GET /logout/oidc
GET /connect/logout
GET /api/apps
POST /api/apps
GET /apps/{appId}/logo
DELETE /api/apps/{appId}
GET /api/apps/{appId}/redirect-settings
PUT /api/apps/{appId}/redirect-settings
GET /api/apps/{appId}/users
POST /api/apps/{appId}/users
POST /api/users/{userId}/email
POST /api/users/{userId}/status
POST /api/users/{userId}/role
POST /api/users/{userId}/password
POST /api/users/{userId}/password-reset
POST /api/users/{userId}/login-links
DELETE /api/users/{userId}
POST /api/sessions/{sessionId}/revoke
POST /token
GET /userinfo
POST /introspect
POST /revoke
GET /help
POST /api/help/chat
App Formatting
Each app can optionally store one 1024x1024 PNG, JPEG, or WebP logo and app-specific background, foreground/accent, text, surface/card, sign-in button, and button-text colors. Apps can also customize the email button label, corner radius, hosted-page footer text, and hosted-page copyright footer text. The admin UI accepts normal logo file uploads and formatting selections. Formatting is used for hosted sign-in screens, check-email/login-link pages, and login emails. Uploaded logos are served from GET /apps/{appId}/logo.
Hosted Login Modes
Hosted app login starts at /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=.... Browser clients should include PKCE code_challenge and code_challenge_method=S256. Add response_format=json or format=json to GET /authorize and POST /authorize when an app wants JSON responses instead of HTML pages.
Passwordless apps show an email form and send a single-use, time-limited login link through UU Notifications only when the address belongs to an enabled app user. Unknown or disabled email addresses receive the same neutral confirmation response, but no email is sent. Username/password browser apps show a username and password form, verify the app-scoped user password hash, and then redirect back with the same short-lived OAuth authorization code shape. If an app requires 2FA, primary login pauses at a short-lived challenge using the strongest available allowed method: authenticator app, email code, or SMS code. Email and SMS methods must be verified factors before they count as enrolled; first-time authenticator-app 2FA shows a QR code and manual key through POST /mfa/setup/totp, first-time email 2FA sends a setup code to the app user's account email through UU Notifications, and first-time SMS 2FA prompts for a phone number and sends a verification code through POST /mfa/setup/sms. POST /mfa/challenge issues the final authorization code only after a valid code. Username/password mobile apps show the credential form inside ASWebAuthenticationSession, run the same challenge when required, and only then redirect to the native callback with a one-time mobile handoff ticket. The token exchange does not change between browser modes, and mobile apps still complete with POST /mobile/complete.
SSO_EMAIL_NOTIFICATION_HUB_BASE_URL=https://notifications.spsw.dev
SSO_EMAIL_NOTIFICATION_HUB_APP_ID=
SSO_EMAIL_NOTIFICATION_HUB_TOKEN=
SSO_SMS_NOTIFICATION_HUB_BASE_URL=https://notifications.spsw.dev
SSO_SMS_NOTIFICATION_HUB_APP_ID=
SSO_SMS_NOTIFICATION_HUB_TOKEN=
PUBLIC_BASE_URL=https://sso.spsw.dev
ADMIN_SSO_CLIENT_ID=sso-admin
ADMIN_SSO_REDIRECT_URI=https://sso.spsw.dev/sso/callback
OIDC_PRIVATE_KEY_FILE=/etc/sso-server-oidc-private-key.pem
OIDC_KEY_ID=sso-prod-1
OIDC_PREVIOUS_PUBLIC_KEYS_FILE=
LOGIN_LINK_TTL_MINUTES=30
AUTH_CODE_TTL_MINUTES=5
SESSION_TTL_MINUTES=480
REFRESH_TOKEN_TTL_MINUTES=43200
CLIENT_SECRET_GRACE_MINUTES=10080
Logged-In API Help
Logged-in SSO admins can open /help for a read-only API help chat. The browser never receives the model API key; it posts questions to POST /api/help/chat, and the SSO server sends a docs-scoped request to the configured model. The assistant has no tools and no permission to mutate SSO configuration.
SSO_HELP_GATEWAY_URL=https://jonathanhays.ai/sso-api-help/v1/sso-api-help
SSO_HELP_GATEWAY_TOKEN=...
OAuth and OIDC Flow
Apps start login at /authorize with response_type=code. Passwordless apps complete sign-in after a login link is accepted. Username/password apps complete sign-in after the hosted credential form succeeds. Both modes redirect back to the app with a short-lived auth code, and apps exchange that code at POST /token with their client secret. If the authorization request used PKCE, the token request must include the matching code_verifier. Clients that request openid receive a signed id_token, can fetch provider metadata from discovery, and can read claims from GET /userinfo. SSO includes app-scoped role claims as role, roles, app_role, and is_superuser. Clients that request offline_access receive a refresh token only when the app client explicitly allows refresh tokens; refresh tokens rotate on every use, and reuse revokes the token family.
GET /.well-known/openid-configuration
GET /jwks.json
GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20email%20profile&state=...&code_challenge=...&code_challenge_method=S256&response_format=json
POST /token { grant_type=authorization_code, client_id, client_secret, redirect_uri, code, code_verifier }
POST /token { grant_type=refresh_token, client_id, client_secret, refresh_token }
GET /userinfo Authorization: Bearer {access_token}
POST /introspect
POST /revoke
Server-to-Server OAuth
For a normal web server that wants to use SSO login, create an app in the SSO admin site, open its Manage page, and use the client_id from Client Details. Copy the one-time client_secret shown after app creation. If the secret was missed or is not stored anywhere safe, use Rotate Client Secret and copy the newly displayed value; old secrets cannot be viewed later because SSO stores only hashes. Add the server's exact callback URL as an Allowed Redirect URI, create the app users who may sign in, and store SSO_BASE_URL, SSO_CLIENT_ID, SSO_CLIENT_SECRET, and SSO_REDIRECT_URI in the server environment.
The server then needs two routes: a login route and a callback route. The login route creates a random state and PKCE verifier/challenge, stores the state and verifier in the user's server-side session, then redirects the browser to SSO /authorize. The callback route verifies the returned state, exchanges the returned code at SSO POST /token, verifies the id_token if openid was requested, and creates the server's own logged-in session.
GET https://sso.spsw.dev/authorize?response_type=code&client_id=SSO_CLIENT_ID&redirect_uri=SSO_REDIRECT_URI&scope=openid%20email%20profile&state=STATE&code_challenge=PKCE_CHALLENGE&code_challenge_method=S256
POST https://sso.spsw.dev/token
{
"grant_type": "authorization_code",
"client_id": "SSO_CLIENT_ID",
"client_secret": "SSO_CLIENT_SECRET",
"redirect_uri": "SSO_REDIRECT_URI",
"code": "CODE_FROM_CALLBACK",
"code_verifier": "ORIGINAL_PKCE_VERIFIER"
}
Use SSO for server-to-server integration when a human signs in through the normal authorization-code/OIDC flow and a trusted backend exchanges the returned code. Keep the app client_secret only on that backend, validate state, exchange the code at POST /token, verify the id_token when openid was requested, and then create an app session or pass the access token to another trusted service. The receiving service can validate the access token with POST /introspect using the same app client_id and client_secret, or read user claims from GET /userinfo.
For machine-only automation with no human user, create an app in server-to-server mode. The caller exchanges its client credentials directly at POST /token; the returned bearer token represents the app/client itself.
POST /token
grant_type=client_credentials
client_id=APP_CLIENT_ID
client_secret=APP_CLIENT_SECRET
scope=api:read
Resource servers should validate machine tokens with POST /introspect. Active machine tokens return token_use=client_credentials, client_id, scope, and sub=client:APP_CLIENT_ID. They do not work with /userinfo and do not receive refresh tokens.
Browser -> GET /authorize?response_type=code&client_id=APP_CLIENT_ID&redirect_uri=APP_CALLBACK&scope=openid%20email%20profile&state=STATE&code_challenge=PKCE_CHALLENGE&code_challenge_method=S256
SSO -> APP_CALLBACK?code=AUTH_CODE&state=STATE
App backend -> POST /token { grant_type=authorization_code, client_id, client_secret, redirect_uri, code, code_verifier }
App backend -> verify id_token with /.well-known/openid-configuration and /jwks.json
Service A -> Service B Authorization: Bearer {access_token}
Service B -> POST /introspect { client_id, client_secret, token }
Request offline_access only when the app needs a long-lived user grant and refresh tokens are enabled for that SSO app. Refresh tokens rotate on every use, and reuse revokes the token family.
Native Mobile Handoff
Native apps that use ASWebAuthenticationSession can start at /mobile/authorize with the same app client_id, allowed native redirect_uri, scopes, state, and optional PKCE challenge. Passwordless apps send a login link; accepted links redirect to the native callback with a short-lived one-time ticket instead of an OAuth code. Username/password apps keep the full hosted credential entry inside ASWebAuthenticationSession, then run the same hosted MFA challenge when the app requires 2FA. After the MFA code verifies, SSO returns a bare 303 See Other directly to the native callback with the same kind of one-time ticket. The app then redeems that ticket at POST /mobile/complete; no client secret is required for this mobile completion endpoint. If the mobile authorization request used PKCE, completion must include the matching code_verifier.
Username/password apps also expose hosted password reset at GET /password-reset/request?client_id=YOUR_CLIENT_ID. Reset requests use a neutral response, only enabled users receive email, reset links are one-time and time-limited, and completing a reset revokes that app user's active sessions.
GET /mobile/authorize?client_id=...&redirect_uri=silverpine%3A%2F%2Flogin%2Fcallback&scope=openid%20email%20profile&state=...&code_challenge=...&code_challenge_method=S256
native callback: silverpine://login/callback?ticket=...&state=...
POST /mobile/complete { client_id, ticket, code_verifier }
Admins can also generate a one-time mobile login code from an individual user's SSO page for apps they can access. The displayed value is a mobile handoff ticket, not a normal OAuth authorization code, and is redeemed with the same POST /mobile/complete endpoint using the app's client_id.
iOS apps should keep the browser login in ASWebAuthenticationSession, then redeem the returned ticket with a normal HTTPS request from the app.
import AuthenticationServices
import CryptoKit
import Foundation
import Security
extension Data {
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
func randomPKCEVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
precondition(status == errSecSuccess)
return Data(bytes).base64URLEncodedString()
}
func pkceS256Challenge(_ verifier: String) -> String {
let digest = SHA256.hash(data: Data(verifier.utf8))
return Data(digest).base64URLEncodedString()
}
let callbackScheme = "silverpine"
let redirectURI = "silverpine://login/callback"
let state = randomBase64URL()
let codeVerifier = randomPKCEVerifier()
let codeChallenge = pkceS256Challenge(codeVerifier)
var components = URLComponents(string: "https://sso.spsw.dev/mobile/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: "YOUR_CLIENT_ID"),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: "openid email profile"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256")
]
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: callbackScheme
) { callbackURL, error in
guard
let callbackURL,
let callback = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let ticket = callback.queryItems?.first(where: { $0.name == "ticket" })?.value,
callback.queryItems?.first(where: { $0.name == "state" })?.value == state
else { return }
// Redeem ticket with POST /mobile/complete.
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false
session.start()
POST https://sso.spsw.dev/mobile/complete
Content-Type: application/json
{
"client_id": "YOUR_CLIENT_ID",
"ticket": "TICKET_FROM_CALLBACK",
"code_verifier": "ORIGINAL_CODE_VERIFIER"
}
The code_verifier sent to /mobile/complete is the original random verifier string, not the SHA-256 hash and not the code_challenge. For S256, generate a 43-128 character verifier, hash the verifier's UTF-8 bytes, base64url-encode the hash bytes, and send that encoded value as code_challenge on /mobile/authorize.
Integration Verification
Run npm run smoke:oidc with SSO_BASE_URL, SSO_ADMIN_TOKEN, SSO_CLIENT_ID, SSO_CLIENT_SECRET, SSO_REDIRECT_URI, and SSO_USER_EMAIL after deploys or key changes. The script creates an admin-only login link without sending email, verifies discovery, exchanges an authorization code with PKCE, validates the ID token against JWKS, calls userinfo, and revokes the smoke session.
The SSO admin audit log should show this chain for the app:
login_link.created
login_link.consumed
auth_code.issued
auth_code.exchanged
session.created
Security Hardening
Browser form posts are checked against the SSO origin when Origin or Referer is present. Login, authorize, token, introspection, revocation, login-link send, and password-reset endpoints use in-process rate limits. Public and admin pages send basic hardening headers including content-type sniffing protection, same-origin referrer policy, frame denial, and a restrictive content security policy.
OIDC Claims
The provider signs ID tokens with RS256. The stable subject claim is the app-scoped SSO user id. The email scope adds email and email_verified; the profile scope adds name and preferred_username.
Current signing key id: sso-prod-1. JWKS publishes the current key plus any configured previous public keys so old ID tokens remain verifiable until they expire.
Admin Access
Admin browser login uses this service's own hosted SSO flow through the configured ADMIN_SSO_* app client. The legacy password POST endpoint remains hidden for break-glass use, while Basic auth and Bearer admin token support remain available for operational automation. Superusers and legacy admin credentials can see and manage all apps. Standard SSO admins can see and manage only the apps they created, including app users, sessions, audit events, and API management responses for those apps. App and user management pages expose operational controls for graceful client-secret rotation, per-client refresh-token policy, per-app 2FA requirement and allowed-method policy, redirect settings, user enable/disable, login-link history, session visibility, security events, and admin session revocation.