Product7 MIN READ126 / 129

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:

  1. Google proves who the customer is. The backend verifies a Google-issued ID token before touching account data.
  2. 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

  1. The client obtains a Google ID token for the configured client ID.
  2. LoginChecker.decode rejects a blank token with 400 GOOGLE_TOKEN_REQUIRED.
  3. The Google decoder validates the token signature against Google’s JWK set, the issuer (https://accounts.google.com or accounts.google.com), the audience (GOOGLE_CLIENT_ID), and standard time claims. A decoder failure returns 401 INVALID_GOOGLE_TOKEN.
  4. LoginChecker.verifiedIdentity requires a non-blank sub, a non-blank email, email_verified = true, and a finite exp claim. A token that passes signature validation but lacks these claims returns 401 UNVERIFIED_GOOGLE_IDENTITY.
  5. LoginService opens a database transaction only after verification:
    • Look up login_identity by provider = 'google' and the Google sub, returning an Optional<LoginIdentity>.
    • If the identity exists, reuse its user_id.
    • If it does not exist, insert app_user, then insert login_identity with the verified email as contact and contact_type = 'email'.
    • Ask TokenService to create the token pair and persist the session.
  6. 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_id is the account ID.
  • session_id is the UUID.
  • expires_at is the refresh-token expiry.
  • is_active uses the schema default true.

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 sid header is present and non-blank.
  • The signed role claim is a single-element list containing the expected value.
  • The database row matches both user_id and session_id.
  • is_active = true.
  • expires_at is 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.
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.