Authentication
Status: implemented Google sign-in with backend access/refresh tokens and persisted sessions. Refresh exchange, sign-out, and session revocation endpoints are not implemented. Updated: 29 October 2026.
Purpose
Authentication has two separate trust boundaries:
- Google proves who the customer is. The backend verifies a Google-issued ID token before touching account data.
- The product backend proves that later requests belong to an active login session. It signs its own short-lived access token and longer-lived refresh token, and checks the session row on every authenticated request.
The backend never accepts a Google token as an application session and never trusts a user ID supplied by the client.
Where it runs
| Entry point | Behavior |
|---|---|
POST /login/v1/google |
Public credential exchange. Accepts { "idToken": "..." }. |
Every other route except /hello and the API-docs routes |
Requires Authorization: Bearer <access token> and ROLE_USER. |
The login controller delegates to LoginService. Guards live in LoginChecker; token issuance is coordinated by TokenService, explicit access/refresh claim chains live in TokenService and shared signing helpers live in TokenFunctions, verification lives in TokenDecoder, and validation guards live in TokenChecker.
How sign-in works
- The client obtains a Google ID token for the configured client ID.
LoginChecker.decoderejects a blank token with400 GOOGLE_TOKEN_REQUIRED.- The Google decoder validates the token signature against Google’s JWK set, the issuer (
https://accounts.google.comoraccounts.google.com), the audience (GOOGLE_CLIENT_ID), and standard time claims. A decoder failure returns401 INVALID_GOOGLE_TOKEN. LoginChecker.verifiedIdentityrequires a non-blanksub, a non-blankemail,email_verified = true, and a finiteexpclaim. A token that passes signature validation but lacks these claims returns401 UNVERIFIED_GOOGLE_IDENTITY.LoginServiceopens a database transaction only after verification:- Look up
login_identitybyprovider = 'google'and the Googlesub, returning anOptional<LoginIdentity>. - If the identity exists, reuse its
user_id. - If it does not exist, insert
app_user, then insertlogin_identitywith the verified email ascontactandcontact_type = 'email'. - Ask
TokenServiceto create the token pair and persist the session.
- Look up
- The transaction commits the account, identity, and session together. A failure rolls all three back.
Insert methods explicitly request the generated bigint id through JDBC key retrieval and return Long; no SQL returning clause is required. PostgreSQL does not automatically return row data from every insert/update.
Account matching uses only the stable (provider, sub) pair. A matching email never merges accounts.
Token contract
| Property | Access token | Refresh token |
|---|---|---|
| Purpose | Call ordinary application routes | Obtain a new access token in a future refresh flow |
Signed role claim |
["user"] |
["refresh"] |
| Default lifetime | 3600 seconds | 2592000 seconds (30 days) |
| Issuer | product.auth.token.issuer (default product-backend) |
Same |
| Subject | Product app_user.id as a decimal string |
Same |
| Session reference | sid JWS header |
Same sid value |
| Algorithm | HS256 | HS256 |
| Key selection | kid header identifies the signing key |
Same |
TokenConfig validates at startup that the active key ID exists, every key ID is non-blank, the issuer is non-blank, the access lifetime is positive, and the refresh lifetime exceeds the access lifetime. Each Base64 key must decode to at least 32 bytes.
Access and refresh lifetimes are configured independently with a positive duration and
duration-unit under product.auth.token.access and product.auth.token.refresh.
Supported units are SECONDS, MINUTES, HOURS, and DAYS; one day is exactly 24 hours.
The refresh lifetime must exceed the access lifetime. For example:
product:
auth:
token:
access:
duration: 15
duration-unit: MINUTES
refresh:
duration: 7
duration-unit: DAYS
The corresponding environment overrides are JWT_ACCESS_DURATION, JWT_ACCESS_DURATION_UNIT,
JWT_REFRESH_DURATION, and JWT_REFRESH_DURATION_UNIT. TokenService.accessToken and TokenService.refreshToken pass all header/body values as named arguments, including algorithm, kid, session reference, issuer, subject, issue/expiry times and role. Top-level createTokenHeader, createTokenBody, signToken and verifyToken functions build complete headers/bodies from those parameters, then sign or verify them. Header defaults are HS256/JWT; body defaults are the current issue time and ["user"]. Signing key/session IDs, issuer, subject and expiry remain explicit; issuance methods continue to pass their configured policy values. Direct issuance methods do not write sessions.
New tokens are signed with JWT_ACTIVE_KID. Retained keys remain accepted for verification until removed from configuration and the application restarts. An unknown or missing kid is rejected without trying another key.
The login response contains:
{
"accessToken": "<signed JWT>",
"refreshToken": "<signed JWT>"
}
The response contains only token strings. Each JWT carries its issue time in iat and expiry in exp, as UTC Unix seconds.
Session contract
TokenService.create generates a random UUID session reference, calls its explicit accessToken and refreshToken methods, and inserts one login_session row:
user_idis the account ID.session_idis the UUID.expires_atis the refresh-token expiry.is_activeuses the schema defaulttrue.
TokenDecoder.forRole validates token context and calls SessionRepository.isActive; acceptance requires all of these conditions:
- The token subject parses as a positive user ID.
- The
sidheader is present and non-blank. - The signed
roleclaim is a single-element list containing the expected value. - The database row matches both
user_idandsession_id. is_active = true.expires_atis strictly later than the database’s current timestamp.
The access token expires before the session. The session remains active until its refresh expiry or until it is marked inactive. Expiry alone does not set is_active = false.
Authenticated requests
SecurityConfiguration registers a JwtAuthenticationProvider using TokenDecoder.forRole() for the user role, then exposes Spring’s manager built from that provider. LoginConfiguration owns the Google decoder, TokenConfiguration owns the backend encoder, and TransactionConfiguration owns the JDBC transaction template. A request succeeds only after signature, issuer, expiry, role, and active-session validation.
The signed role claim is a list. A custom converter maps ["user"] to ROLE_USER and ["refresh"] to ROLE_REFRESH. anyRequest().hasRole("USER") therefore accepts access tokens and rejects refresh tokens.
Authentication and authorization failures use the common JSON error contract:
| Condition | Status | Code |
|---|---|---|
| Missing or invalid bearer credential | 401 |
AUTHENTICATION_REQUIRED or INVALID_ACCESS_TOKEN |
| Authenticated but wrong role | 403 |
ACCESS_DENIED |
Edge cases and current limits
- An existing Google subject reuses its account even if Google later returns a different email. The stored contact is not updated by this flow.
- A first login race can hit the unique
(provider, sub)constraint. The database conflict propagates and the transaction rolls back; a retry resolves to the existing identity. - A refresh token is issued but cannot call ordinary routes. No refresh exchange endpoint exists, so a client cannot yet trade it for a new access token.
- No sign-out or session-revocation endpoint exists. Marking a session inactive in the database takes effect on the next request because validation reads the row each time.
- If the database is unavailable, session validation cannot succeed; authentication fails rather than granting access.
- Google key retrieval is lazy and happens on the first credential decode. The application does not fetch Google keys at startup.
- The client-facing error payload never includes Google ID tokens, backend tokens, SQL, stack traces, or key material. Internal messages and stack traces stay in server logs.
Verification
Run ./mvnw verify from apps/product-backend. Docker must be running because the HTTP integration tests start a disposable PostgreSQL container.
| Test | Covers |
|---|---|
LoginHttpIntegrationTests |
Real HTTP, security, and JDBC wiring with only Google’s remote decoder mocked. Blank, invalid, and unverified credentials; account reuse; role separation; shared session; revocation; malformed body; missing bearer; OpenAPI output; session-write rollback. |
GoogleDecoderTests |
Real RSA verification of Google issuer and configured audience using test-owned keys, without network access. |
TokenDecoderTests |
Retained key acceptance, missing or unknown kid, wrong signature, missing expiry, wrong role, expired token, and foreign issuer. |
ProductEntityDtoIntegrationTests |
Entity JSON contracts and native reflection registration. |
Related code
| Path | Owns |
|---|---|
account/login/LoginController.kt |
Public login route and OpenAPI description. |
account/login/LoginService.kt |
Verification ordering, account resolution, and transaction boundary. |
account/login/LoginChecker.kt |
Blank-token and verified-identity guards. |
account/login/LoginExtension.kt |
Google JWK decoder and claim validation. |
account/login/LoginRepository.kt |
Subject lookup and identity insert. |
account/user/UserAccountService.kt |
Minimal account insert. |
account/token/TokenService.kt |
Token pair orchestration and persisted session creation. |
account/token/TokenDecoder.kt |
Role-specific verification, retained key selection and active-session validation. |
account/token/TokenRole.kt |
Shared signed user/refresh role values. |
account/token/TokenFunctions.kt |
Parameterized top-level header/body construction, signing and decoder-based verification. |
account/token/TokenChecker.kt |
Signing-key strength and known-key guards. |
account/session/SessionRepository.kt |
Session insert and active-session lookup. |
platform/config/SecurityConfiguration.kt |
Stateless filter chain, public routes, and bearer authentication. |
platform/config/TokenConfiguration.kt |
Active-key backend encoder bean. |
platform/config/TransactionConfiguration.kt |
Explicit JDBC transaction template. |
account/login/LoginConfiguration.kt |
Google credential decoder bean. |
platform/config/ApplicationProperties.kt |
Google and token configuration contracts. |