You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
can obtain a new access JWT after the previous JWT expires;
is accepted only by the authorization server's refresh operation, never by protected resources;
can be revoked immediately through Atom's existing session lifecycle;
rotates on every use and detects replay;
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.
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:
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:
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:
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:
Parse the token id and secret without logging either value.
Lock the refresh-token row and parent session (SELECT ... FOR UPDATE).
Verify the HMAC in constant time.
Validate family expiry, token state, session state, active entity, owning tenant state, and tenant binding.
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.
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
Ship schema/migration/config with ATOM_REFRESH_TOKENS_ENABLED=false.
Keep LoginResponse.token unchanged and add new fields without renaming/removing existing fields.
Keep the current refreshSession mutation, mark it deprecated in GraphQL/documentation, and document that it still requires a valid access JWT.
Enable refresh tokens in a non-production environment and migrate clients to accessToken + refreshToken.
Shorten access-token lifetime only after clients have deployed refresh exchange support.
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.
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.
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.
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:
sub,sid, optionaltid).sessionsrow checked online for revocation, expiry, entity lifecycle, and tenant lifecycle.JWT_EXPIRY_SECScontrolling both the JWT lifetime and the session expiry created at login.refreshSessionmutation that requires a currently valid session JWT, extends the existing session, and returns a new JWT.The existing
refreshSessionoperation 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:
Goals
Non-goals
/tokenendpoint.atom_...). Those remain independently managed credentials and are not refresh tokens.refreshSessionmutation 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:
tokenfield as a compatibility alias for the access JWT;accessTokenfield containing the same JWT;accessTokenExpiresAt;refreshToken, returned only in this response;refreshTokenExpiresAt, which is also the family's absolute deadline;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
refreshSessionfield:The request is made without an
Authorizationheader. The refresh credential ininputis 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:
ATOM_REFRESH_TOKENS_ENABLEDfalseATOM_REFRESH_TOKEN_EXPIRY_SECS2592000(30 days)ATOM_ACCESS_TOKEN_EXPIRY_SECSJWT_EXPIRY_SECSJWT_EXPIRY_SECSremains 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:
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_atbecomes the server-side session/family deadline when refresh tokens are enabled. JWTexpremains 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_...inauthenticate_token, Bearer auth, gRPC auth, broker auth, certificate enrollment, or custom endpoint authentication.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:
SELECT ... FOR UPDATE).family_expires_at;replaced_by;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_txvariants.identity/service.rs: token parsing/HMAC verification, lifecycle validation, JWT minting, and orchestration.graphql/auth.rs: thin GraphQL adapter and uniformAppErrormapping.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:
Errbranch;Audit, events, observability, and cleanup
auth.refreshsuccess, deny, and replay/reuse outcomes.secret_hash.Compatibility and rollout
ATOM_REFRESH_TOKENS_ENABLED=false.LoginResponse.tokenunchanged and add new fields without renaming/removing existing fields.refreshSessionmutation, mark it deprecated in GraphQL/documentation, and document that it still requires a valid access JWT.accessToken+refreshToken.refreshSessionortokenis a separate breaking-change issue/release.Acceptance criteria
Functional
tokenremains equal toaccessTokenfor compatibility.Security
Consistency and concurrency
max_connections = 1and does not borrow a second connection mid-transaction.API and operations
refreshSessiondeprecated..env.example/README, and covered by unit tests.cargo fmt --check,cargo clippy -- -D warnings, and the full non-DB test suite pass.cargo test -- --include-ignoredagainst PostgreSQL.Required test plan
Unit tests
token == accessToken.Database integration tests
max_connections = 1completes without deadlock.Replay and concurrency tests
GraphQL/API contract tests
refreshTokenworks withoutAuthContext/Authorization.apidocs/graphql-schema.graphqlmatches runtime SDL.Audit/cache/event tests
PR completion checklist
.env.example, GraphQL SDL, and relevant product documentation updated.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