Single sign-on and two-factor authentication
Iskue supports optional single sign-on via OpenID Connect with Google and Microsoft (Azure AD). The flow is backend-driven: the backend runs the authorisation-code exchange, resolves the provider identity to a local account, mints the same Iskue JWT that password login issues, and redirects the browser back to the web app. A provider activates only when its client ID is set — leave the OAUTH2_* variables unset and Iskue remains password-only, with no OAuth2 beans created at all. Sign-in buttons appear on the login page automatically when a provider is configured.
How the login flow works
- The web app asks
GET /api/v1/auth/providerswhich providers to offer; the response lists each active provider with itsid,labelandloginUrl(for example/oauth2/authorization/google). The list is empty when SSO is not configured. - The browser navigates to
/oauth2/authorization/googleor/oauth2/authorization/microsofton the backend, which starts the authorisation-code flow at the provider. - The provider redirects back to the backend callback
/login/oauth2/code/{registrationId}— this is the redirect URI you must register with the provider. - On success the backend resolves the identity to a local user, mints an Iskue JWT and redirects the browser to
OAUTH2_SUCCESS_REDIRECTwith the token appended as?token=<jwt>. The SPA callback route stores it and the session proceeds exactly as after password login. - On failure the browser is sent to
OAUTH2_FAILURE_REDIRECT; when the failure occurs in Iskue's own resolution step (for example an unverified email), areasonquery parameter is appended.
In the full-stack Docker deployment the frontend container's nginx proxies /oauth2/authorization/ and /login/oauth2/ to http://backend:8080, so the whole flow runs on the single public origin (http://localhost:8088 by default). The backend sets server.forward-headers-strategy: framework, so it honours X-Forwarded-* headers and builds redirect URIs from the public URL — the bundled frontend/nginx.conf already sends these headers.
Environment variables
All SSO settings live in .env.example and map to app.oauth2.* in backend/src/main/resources/application.yml. Defaults below are the bare-backend defaults from application.yml; docker-compose.yml overrides the two redirects to the :8088 origin (see the note after the table).
| Variable | Default | Purpose |
|---|---|---|
| OAUTH2_GOOGLE_CLIENT_ID | (unset) | Google OAuth client ID. Setting it activates Google sign-in. |
| OAUTH2_GOOGLE_CLIENT_SECRET | (unset) | Google OAuth client secret. |
| OAUTH2_MICROSOFT_CLIENT_ID | (unset) | Azure AD application (client) ID. Setting it activates Microsoft sign-in. |
| OAUTH2_MICROSOFT_CLIENT_SECRET | (unset) | Azure AD client secret. |
| OAUTH2_MICROSOFT_TENANT | common | Azure AD tenant: common, organizations, consumers, or a specific tenant ID. |
| OAUTH2_SUCCESS_REDIRECT | http://localhost:5173/oauth2/callback | Where the backend sends the browser after minting the Iskue token. Must point at the SPA's /oauth2/callback route on the SPA origin. |
| OAUTH2_FAILURE_REDIRECT | http://localhost:5173/login?error=sso | Where the backend sends the browser when SSO fails. |
Under docker compose --profile full up, the compose file defaults the redirects to http://localhost:8088/oauth2/callback and http://localhost:8088/login?error=sso to match the published frontend port. If you deploy under your own domain, set both to that origin — the success redirect must match the SPA origin or the token never reaches the app.
Register the redirect URIs
The backend's callback follows Spring Security's template {baseUrl}/login/oauth2/code/{registrationId}. Register the following authorised redirect URIs at each provider, substituting your public backend origin for <backend>:
<backend>/login/oauth2/code/google
<backend>/login/oauth2/code/microsoft- In Google Cloud Console, create an OAuth client ID of type *Web application*.
- Add
<backend>/login/oauth2/code/googleas an authorised redirect URI. - Set
OAUTH2_GOOGLE_CLIENT_IDandOAUTH2_GOOGLE_CLIENT_SECRETfrom the created client. - Iskue uses Spring Security's built-in Google OIDC endpoints, requesting the standard
openid,profileandemailscopes — no extra API enablement is needed.
Microsoft (Azure AD)
- In Azure, create an App registration; under *Authentication* add a Web platform with redirect URI
<backend>/login/oauth2/code/microsoft. - Create a client secret and set
OAUTH2_MICROSOFT_CLIENT_IDandOAUTH2_MICROSOFT_CLIENT_SECRET. - Set
OAUTH2_MICROSOFT_TENANTto control who may sign in:common(any Microsoft account, the default),organizations(any work/school tenant),consumers, or a specific tenant ID to restrict to your directory. - Iskue talks to
https://login.microsoftonline.com/<tenant>/oauth2/v2.0/...with theopenid,profileandemailscopes and authenticates as a confidential client (client secret, basic auth).
Configure and restart
Add the SSO block to your .env (the same values are commented in .env.example), then recreate the backend container so it picks up the new environment.
# .env — Single sign-on (OIDC), optional
OAUTH2_SUCCESS_REDIRECT=http://localhost:8088/oauth2/callback
OAUTH2_FAILURE_REDIRECT=http://localhost:8088/login?error=sso
# Google — redirect URI to register: <backend>/login/oauth2/code/google
OAUTH2_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
OAUTH2_GOOGLE_CLIENT_SECRET=your-client-secret
# Microsoft (Azure AD) — redirect URI to register: <backend>/login/oauth2/code/microsoft
OAUTH2_MICROSOFT_CLIENT_ID=your-application-id
OAUTH2_MICROSOFT_CLIENT_SECRET=your-client-secret
OAUTH2_MICROSOFT_TENANT=commondocker compose --profile full up -d backend
# Verify: one entry per active provider; [] means SSO is off
curl -s http://localhost:8088/api/v1/auth/providersSuccess and failure redirect semantics
OAUTH2_SUCCESS_REDIRECT receives the browser with ?token=<jwt> appended; the SPA's /oauth2/callback route consumes the token and enters the app. OAUTH2_FAILURE_REDIRECT receives the browser whenever provider authentication fails; when Iskue's own account resolution rejects the sign-in, it also appends reason=<message> so the login page can explain what happened.
The session token is carried in the redirect's query string. Always run production deployments over HTTPS, and keep OAUTH2_SUCCESS_REDIRECT pointed at the Iskue SPA callback route only — never at a third-party URL.
Account provisioning and linking
Each successful provider sign-in is resolved to a local user in this order:
- Known identity — a user already linked to this
(provider, subject)pair signs straight in. The subject is the stable OIDC identifier, so the link survives email changes at the provider. - Verified-email linking — otherwise, if the provider asserts a verified email that matches an existing account, that account is linked to the provider and signed in. If the email matches but the provider has not verified it, the sign-in is refused (to prevent account takeover) with a message telling the user to sign in with their password or verify the email at the provider.
- Auto-provisioning — otherwise a new account is created from the provider's email and display name. The first user ever created becomes an admin, mirroring local registration; everyone after that joins as a member. A provider that returns no email at all is rejected.
Deactivated accounts stay locked out of SSO. Accounts created via SSO have no password; attempting password login for one returns "This account uses single sign-on; sign in with your provider". Every path is audited: USER_LOGIN on sign-in, USER_SSO_LINKED when an existing account links a provider, USER_REGISTERED when a new account is provisioned.
Two-factor authentication (TOTP)
Local (password) accounts can enable time-based one-time passwords per RFC 6238 — HMAC-SHA1, 6 digits, 30-second step, the defaults every mainstream authenticator app uses. Verification tolerates one step of clock drift in either direction. 2FA applies to password accounts only; SSO accounts delegate authentication entirely to their provider, and the API rejects enrolment for them.
Enrolment
Users enrol self-service under Account security in the app. The equivalent API flow, for reference:
TOKEN='<an Iskue session JWT>'
BASE=http://localhost:8088
# 1. Begin enrolment — returns the shared secret and an otpauth:// URI (rendered as a QR code in the app)
curl -s -X POST "$BASE/api/v1/mfa/enroll" -H "Authorization: Bearer $TOKEN"
# 2. Confirm with the first code from the authenticator app — activates 2FA
# and returns 10 one-time recovery codes. They are shown exactly once.
curl -s -X POST "$BASE/api/v1/mfa/enroll/confirm" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"123456"}'Recovery codes are formatted XXXX-XXXX from an alphabet that omits easily-confused characters (no 0/O or 1/I/L). They are stored bcrypt-hashed — the server cannot re-display them — and each code is single-use: once accepted at login it is marked consumed and never works again. Tell users to store them like passwords.
Logging in with 2FA
Login becomes a two-step challenge. The password step no longer grants a session; instead it returns a short-lived ticket that only the 2FA endpoint accepts.
# Step 1: password. For a 2FA account this returns {"mfaRequired":true,"mfaToken":"..."}
curl -s -X POST "$BASE/api/v1/auth/login" -H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret"}'
# Step 2: exchange the challenge plus a TOTP or recovery code for the session token
curl -s -X POST "$BASE/api/v1/auth/login/mfa" -H "Content-Type: application/json" \
-d '{"mfaToken":"<mfaToken from step 1>","code":"123456"}'The mfaToken challenge is a 5-minute JWT scoped to mfa and carries no role claim, so it cannot authenticate ordinary API calls — only the /api/v1/auth/login/mfa exchange. The code field accepts either the current 6-digit TOTP or an unused recovery code (spaces and case are normalised).
Rotating recovery codes and disabling 2FA
POST /api/v1/mfa/recovery-codeswith a valid current code replaces all recovery codes with a fresh set of 10 and invalidates the old ones — use it after some codes have been spent.POST /api/v1/mfa/disablewith a valid current code (TOTP or recovery) turns 2FA off and deletes the stored recovery codes.- Both actions are self-service under Account security, and enabling/disabling is written to the audit log (
MFA_ENABLED/MFA_DISABLED).
Troubleshooting
- Provider shows a redirect-URI mismatch — the URI registered at the provider must equal
<backend>/login/oauth2/code/googleor.../microsoftexactly, including scheme and port. Behind a proxy, ensure it forwardsX-Forwarded-ProtoandX-Forwarded-Host(the bundledfrontend/nginx.confdoes), otherwise the backend builds the callback from its internal address. - No SSO buttons appear —
GET /api/v1/auth/providersreturns[]until a*_CLIENT_IDis set and the backend has been restarted with the new environment. - Browser lands on
/login?error=sso— check thereasonparameter and the backend log lineOAuth2 sign-in failed: .... A common cause is an existing account whose email the provider has not verified. - Signed in but immediately logged out —
OAUTH2_SUCCESS_REDIRECTpoints at the wrong origin, so the token was delivered to a page that is not the Iskue SPA callback.