Skip to content

Feature: Add rotating refresh tokens for session authentication #100

Description

@arvindh123

Summary

Add first-class, rotating refresh tokens to Atom's session authentication flow. Login will continue to issue a short-lived ES256 JWT access token, and—when refresh tokens are enabled—will also issue a long-lived opaque refresh token bound to the same server-side session. A new GraphQL mutation will exchange a valid refresh token for a new access-token/refresh-token pair without requiring a still-valid access JWT.

This must be implemented as a production-grade credential lifecycle, including one-time rotation, replay detection, revocation, bounded lifetime, transactional event publication, cache correctness, backward-compatible rollout, and tests for concurrent exchanges.

Background / current behavior

Atom currently has:

  • ES256 JWTs containing identity and session claims (sub, sid, optional tid).
  • A PostgreSQL sessions row checked online for revocation, expiry, entity lifecycle, and tenant lifecycle.
  • JWT_EXPIRY_SECS controlling both the JWT lifetime and the session expiry created at login.
  • A GraphQL refreshSession mutation that requires a currently valid session JWT, extends the existing session, and returns a new JWT.
  • Logout and entity/tenant lifecycle operations that revoke sessions.
  • KEK-keyed HMAC verification patterns for access-token/shared-key secrets.

The existing refreshSession operation is not a refresh-token flow: once the JWT expires, GraphQL authentication rejects the request before the resolver can refresh it. Clients therefore cannot use a separate long-lived credential to obtain a new short-lived JWT.

Problem statement

Clients must either keep JWTs relatively long-lived or force users/services to authenticate again whenever a JWT expires. Long-lived bearer JWTs increase the exposure window after theft. Atom needs a separate credential that:

  1. can obtain a new access JWT after the previous JWT expires;
  2. is accepted only by the authorization server's refresh operation, never by protected resources;
  3. can be revoked immediately through Atom's existing session lifecycle;
  4. rotates on every use and detects replay;
  5. never appears in plaintext in the database, audit log, event outbox, tracing output, or metrics labels.

Goals

  • Issue a short-lived ES256 access JWT and an opaque refresh token at login.
  • Exchange a refresh token without requiring a valid access JWT.
  • Rotate refresh tokens atomically and make each token single-use.
  • Detect reuse of a consumed/replaced refresh token and revoke the associated session/token family.
  • Maintain a fixed absolute refresh/session deadline so repeated rotation cannot create an immortal session.
  • Preserve immediate revocation through session, entity, tenant, and credential lifecycle checks.
  • Preserve existing GraphQL clients during a feature-flagged migration period.
  • Follow Atom's transaction, audit/outbox, cache-invalidation, and single-pool-connection invariants.

Non-goals

  • Implementing a complete OAuth 2.0 authorization server or /token endpoint.
  • Authorization-code, PKCE, client registration, consent, scope negotiation, DPoP, or mTLS sender-constrained tokens.
  • Refreshing Atom scoped access-token credentials (atom_...). Those remain independently managed credentials and are not refresh tokens.
  • Device management UI or listing individual refresh-token descendants.
  • Browser HttpOnly refresh-cookie delivery in the first PR. It may be added separately; the initial GraphQL contract returns the refresh token once in the response, matching the existing API-token delivery model.
  • Removing the current refreshSession mutation in the same PR.

Product behavior

Login

When refresh tokens are enabled, successful password/shared-key login creates one server-side session and one refresh-token family, then returns:

  • the existing token field as a compatibility alias for the access JWT;
  • a new accessToken field containing the same JWT;
  • accessTokenExpiresAt;
  • a plaintext refreshToken, returned only in this response;
  • refreshTokenExpiresAt, which is also the family's absolute deadline;
  • the existing identity/session fields.

When refresh tokens are disabled, existing behavior remains unchanged and the refresh-token response fields are null.

Refresh exchange

Add a new GraphQL mutation distinct from the existing refreshSession field:

input RefreshTokenInput {
  refreshToken: String!
}

type TokenPairResponse {
  token: String! # compatibility alias for accessToken
  accessToken: String!
  refreshToken: String!
  accessTokenExpiresAt: String!
  refreshTokenExpiresAt: String!
  entityId: ID!
  sessionId: ID!
}

extend type MutationRoot {
  refreshToken(input: RefreshTokenInput!): TokenPairResponse!
}

The request is made without an Authorization header. The refresh credential in input is the authentication mechanism. This is necessary because the normal GraphQL request wrapper rejects an expired JWT before resolver execution.

On success, the presented refresh token is consumed and a replacement refresh token plus a new access JWT are returned. The replacement inherits the original family's absolute expiry; rotation does not extend it.

Logout and lifecycle changes

Revoking the parent session makes every refresh token in that family unusable immediately. Existing logout, entity deletion/deactivation, tenant deletion/deactivation, and session-revocation paths continue to be authoritative. Physical session deletion removes refresh-token rows through ON DELETE CASCADE.

Error behavior

Malformed, unknown, expired, revoked, consumed, wrong-secret, inactive-entity, and inactive/deleted-tenant cases return one generic unauthorized GraphQL error. Responses must not reveal whether a token id, session, entity, or tenant exists.

A reused consumed token additionally revokes the parent session and all unconsumed descendants before returning the generic error.

Configuration

Add validated configuration with documented defaults:

Variable Proposed default Meaning
ATOM_REFRESH_TOKENS_ENABLED false Feature flag for backward-compatible rollout
ATOM_REFRESH_TOKEN_EXPIRY_SECS 2592000 (30 days) Absolute refresh-token family/session lifetime
ATOM_ACCESS_TOKEN_EXPIRY_SECS inherit JWT_EXPIRY_SECS Access JWT lifetime; compatibility bridge

JWT_EXPIRY_SECS remains supported during migration. Configuration must fail fast for zero/invalid values and when refresh-token lifetime is not greater than access-token lifetime. Document a recommended production access-token lifetime of 10–15 minutes, without silently changing the current default in this PR.

Data model

Create the next numbered migration with a separate table so consumed tokens can be retained for replay detection:

CREATE TABLE refresh_tokens (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id      UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    secret_hash     BYTEA NOT NULL,
    family_expires_at TIMESTAMPTZ NOT NULL,
    consumed_at     TIMESTAMPTZ,
    revoked_at      TIMESTAMPTZ,
    replaced_by     UUID REFERENCES refresh_tokens(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (family_expires_at > created_at)
);

Add indexes supporting token-id lookup, session/family revocation, expiry cleanup, and the single active descendant invariant. Final names/constraints may differ, but the schema must retain enough history to identify reuse of an old token.

sessions.expires_at becomes the server-side session/family deadline when refresh tokens are enabled. JWT exp remains the access-token deadline. Existing sessions created before deployment remain valid under their existing expiry and do not receive a refresh token retroactively.

Token format and secret storage

Use an opaque format patterned after Atom API keys:

atom_rt_<32-hex-refresh-token-id>_<64-hex-random-secret>
  • Generate the secret with a cryptographically secure RNG (32 random bytes minimum).
  • Use the embedded UUID for O(1) lookup.
  • Store only a KEK-keyed HMAC-SHA256 digest of the secret.
  • Verify in constant time.
  • Never store or recover the plaintext value.
  • Do not accept atom_rt_... in authenticate_token, Bearer auth, gRPC auth, broker auth, certificate enrollment, or custom endpoint authentication.
  • Refresh-token creation requires ATOM_KEY_ENCRYPTION_KEY; fail closed if it is unavailable.

Rotation and replay algorithm

Implement exchange in one PostgreSQL transaction using the caller's existing connection:

  1. Parse the token id and secret without logging either value.
  2. Lock the refresh-token row and parent session (SELECT ... FOR UPDATE).
  3. Verify the HMAC in constant time.
  4. Validate family expiry, token state, session state, active entity, owning tenant state, and tenant binding.
  5. If the token is valid and unused:
    • mark it consumed;
    • insert its replacement with the same family_expires_at;
    • link replaced_by;
    • mint a short-lived ES256 access JWT for the same session/entity/tenant;
    • enqueue the success domain event in the same transaction;
    • commit through the appropriate audit helper;
    • return the new pair.
  6. If a correctly authenticated token was already consumed/replaced:
    • revoke the parent session and all active descendants in the same transaction;
    • publish/observe a deny/reuse event with token/session identifiers only, never the secret or complete token;
    • invalidate the session cache under a barrier spanning the commit;
    • return the generic unauthorized error.

The row lock must ensure that two concurrent exchanges cannot both succeed. Do not acquire a second pool connection while the transaction is open.

Layering and implementation shape

  • models/session.rs: add response/input models or introduce a focused refresh-token model module.
  • identity/repo.rs: database-only create, lock, consume, rotate, revoke, and cleanup queries with *_in_tx variants.
  • identity/service.rs: token parsing/HMAC verification, lifecycle validation, JWT minting, and orchestration.
  • graphql/auth.rs: thin GraphQL adapter and uniform AppError mapping.
  • config.rs, .env.example, README.md: configuration and migration documentation.
  • apidocs/graphql-schema.graphql: regenerate the checked-in schema.

The service/repo boundary must preserve Atom's invariants:

  • a committed mutation cannot lose its domain event;
  • the success event is enqueued inside the mutation transaction;
  • transport observes only the Err branch;
  • no read-after-commit may turn a committed success into an apparent failure;
  • cache invalidation barriers span mutation commit whenever session state changes;
  • no blocking file I/O or secret exposure;
  • no second pool connection while holding a transaction.

Audit, events, observability, and cleanup

  • Define auth.refresh success, deny, and replay/reuse outcomes.
  • Persist/publish only safe identifiers and reason categories; never include the raw refresh token, its secret portion, or secret_hash.
  • The successful rotation event must be enqueued atomically with rotation.
  • Session revocation caused by replay must be atomic with the reuse event.
  • Add a bounded cleanup path for expired token-history rows. It may be integrated with the existing purge job, but cleanup must retain consumed rows until the family deadline so replay remains detectable.
  • Add non-secret metrics for refresh success, invalid/expired rejection, and replay-triggered revocation if consistent with existing metric conventions. Do not use entity, session, token id, or error strings as metric labels.

Compatibility and rollout

  1. Ship schema/migration/config with ATOM_REFRESH_TOKENS_ENABLED=false.
  2. Keep LoginResponse.token unchanged and add new fields without renaming/removing existing fields.
  3. Keep the current refreshSession mutation, mark it deprecated in GraphQL/documentation, and document that it still requires a valid access JWT.
  4. Enable refresh tokens in a non-production environment and migrate clients to accessToken + refreshToken.
  5. Shorten access-token lifetime only after clients have deployed refresh exchange support.
  6. Removal of refreshSession or token is a separate breaking-change issue/release.

Acceptance criteria

Functional

  • With the feature disabled, login and current GraphQL behavior are unchanged.
  • With the feature enabled, every successful session login returns an access JWT and one opaque refresh token.
  • token remains equal to accessToken for compatibility.
  • A refresh exchange succeeds without an access JWT, including after the previous access JWT has expired.
  • Successful exchange returns a new access JWT and a different refresh token for the same session.
  • The previous refresh token becomes unusable immediately.
  • Rotation never extends the original family/session absolute expiry.
  • Existing pre-migration sessions remain valid but are not made refreshable retroactively.

Security

  • Refresh-token plaintext is returned only at issuance/rotation and is never persisted.
  • Database compromise reveals only KEK-keyed HMAC digests, not usable refresh tokens.
  • HMAC comparison is constant-time.
  • Refresh tokens are rejected by every normal Bearer/API authentication path.
  • Unknown, malformed, wrong-secret, expired, revoked, and lifecycle-invalid tokens return indistinguishable unauthorized responses.
  • Reuse of an authenticated consumed token revokes the complete parent session/family.
  • Logout and all existing entity/tenant/session revocation paths immediately prevent refresh.
  • Tokens, token secrets, and hashes never appear in audit details, outbox payloads, tracing, errors, or metric labels.
  • The endpoint remains protected by the configured GraphQL IP rate limit; any operation-specific limiter added must have documented configuration and tests.

Consistency and concurrency

  • Rotation, replacement insertion, and the success event commit atomically.
  • Replay-triggered session revocation and its event commit atomically.
  • Two concurrent exchanges of the same token produce at most one successful pair.
  • A failed transaction leaves the original token usable and publishes no success event.
  • Session cache invalidation prevents a revoked/replay-compromised session from being repopulated as active.
  • The flow works with max_connections = 1 and does not borrow a second connection mid-transaction.

API and operations

  • GraphQL SDL exposes the documented fields/input and marks refreshSession deprecated.
  • Configuration is validated, included in .env.example/README, and covered by unit tests.
  • The next numbered migration applies cleanly to an existing database and supports rollback through normal deployment restoration procedures.
  • Expired history has a documented and tested cleanup/retention path.
  • cargo fmt --check, cargo clippy -- -D warnings, and the full non-DB test suite pass.
  • DB-gated tests pass with cargo test -- --include-ignored against PostgreSQL.
  • Checked-in GraphQL/API documentation is regenerated and contract checks pass.

Required test plan

Unit tests

  • Token generation has the exact prefix/id/secret structure and sufficient entropy length.
  • Parser rejects bad prefix, bad UUID length, separators, non-hex content, truncated secrets, and trailing data.
  • HMAC verification accepts the correct secret and rejects a modified secret.
  • Only digests are exposed by persistence models/debug output.
  • Config defaults, overrides, invalid numbers, zero values, and lifetime ordering.
  • Response conversion preserves token == accessToken.

Database integration tests

  • Login creates one session and one active refresh-token row with no plaintext secret.
  • Exchange consumes the original and inserts exactly one linked replacement.
  • Replacement inherits the original absolute deadline.
  • Refresh succeeds after access JWT expiry while session/family remains active.
  • Expired family/token is rejected.
  • Revoked session, inactive/deleted entity, and inactive/frozen/deleted tenant are rejected.
  • Logout invalidates every descendant.
  • Entity and tenant delete/restore semantics do not resurrect revoked refresh-token families.
  • Purging a session/entity/tenant removes refresh-token records through existing cascades.
  • Cleanup deletes history only after the required replay-detection retention deadline.
  • max_connections = 1 completes without deadlock.

Replay and concurrency tests

  • Reuse of a consumed token revokes the session and rejects the newest descendant afterward.
  • Two simultaneous exchanges of one token yield exactly one success and leave no two active descendants.
  • Rotation racing logout cannot commit a usable token for a revoked session.
  • Rotation racing entity or tenant deactivation cannot escape the lifecycle lock/invariant.
  • Injected failure before commit preserves the old token and creates no replacement/event.

GraphQL/API contract tests

  • Login schema and response remain compatible when the feature is disabled.
  • Enabled login returns all new fields.
  • refreshToken works without AuthContext/Authorization.
  • An expired Bearer header is not required; documentation explicitly tells clients to omit it for refresh.
  • Generic errors do not disclose token/session existence.
  • Scoped access tokens/API keys cannot invoke refresh as refresh credentials.
  • Generated apidocs/graphql-schema.graphql matches runtime SDL.

Audit/cache/event tests

  • Success event exists only after committed rotation.
  • Rollback creates no success event.
  • Replay revocation emits one safe deny/reuse event.
  • No event/audit/log representation contains the raw token or secret.
  • Cache-hit and cache-miss authentication both deny access after replay revocation.

PR completion checklist

  • Migration and indexes reviewed for lock behavior and cleanup cost.
  • Threat model/security-sensitive code called out in the PR description.
  • API compatibility and rollout instructions included in the PR description.
  • Unit, integration, concurrency, cache, audit/event, and contract tests included.
  • README, .env.example, GraphQL SDL, and relevant product documentation updated.
  • No unrelated OAuth flows or breaking schema removals included.
  • Formatting, clippy, unit tests, ignored DB tests, proto/API contract checks, and documentation generation pass.

Estimated effort

Approximately 5–8 engineering days for implementation, migration, concurrency/security tests, documentation, and review. Browser HttpOnly-cookie delivery or full OAuth compatibility should be scoped as follow-up work.

Security references

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions