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/providers which providers to offer; the response lists each active provider with its id, label and loginUrl (for example /oauth2/authorization/google). The list is empty when SSO is not configured.
  • The browser navigates to /oauth2/authorization/google or /oauth2/authorization/microsoft on 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_REDIRECT with 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), a reason query 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).

VariableDefaultPurpose
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_TENANTcommonAzure AD tenant: common, organizations, consumers, or a specific tenant ID.
OAUTH2_SUCCESS_REDIRECThttp://localhost:5173/oauth2/callbackWhere 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_REDIRECThttp://localhost:5173/login?error=ssoWhere 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>:

For the default full-stack deployment: http://localhost:8088/login/oauth2/code/google and http://localhost:8088/login/oauth2/code/microsoft
<backend>/login/oauth2/code/google
<backend>/login/oauth2/code/microsoft

Google

  • In Google Cloud Console, create an OAuth client ID of type *Web application*.
  • Add <backend>/login/oauth2/code/google as an authorised redirect URI.
  • Set OAUTH2_GOOGLE_CLIENT_ID and OAUTH2_GOOGLE_CLIENT_SECRET from the created client.
  • Iskue uses Spring Security's built-in Google OIDC endpoints, requesting the standard openid, profile and email scopes — 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_ID and OAUTH2_MICROSOFT_CLIENT_SECRET.
  • Set OAUTH2_MICROSOFT_TENANT to 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 the openid, profile and email scopes 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.

Configure only the provider(s) you use; the other stays inactive
# .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=common
docker compose --profile full up -d backend

# Verify: one entry per active provider; [] means SSO is off
curl -s http://localhost:8088/api/v1/auth/providers

Success 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-codes with 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/disable with 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/google or .../microsoft exactly, including scheme and port. Behind a proxy, ensure it forwards X-Forwarded-Proto and X-Forwarded-Host (the bundled frontend/nginx.conf does), otherwise the backend builds the callback from its internal address.
  • No SSO buttons appear — GET /api/v1/auth/providers returns [] until a *_CLIENT_ID is set and the backend has been restarted with the new environment.
  • Browser lands on /login?error=sso — check the reason parameter and the backend log line OAuth2 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_REDIRECT points at the wrong origin, so the token was delivered to a page that is not the Iskue SPA callback.