Sessions had an absolute cap (created_at + SessionAbsoluteTTL) but the JWT path only had per-token TTL on the refresh row, letting a well-behaved client refresh indefinitely. Add chain_started_at to authkit_tokens, copy it forward on every rotation, and reject in RefreshJWT when now > chainStartedAt + RefreshChainAbsoluteTTL. Default 30d, mirroring SessionAbsoluteTTL. Schema, verifier, queries, model, and integration test updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.4 KiB
Go
165 lines
5.4 KiB
Go
package authkit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.juancwu.dev/juancwu/errx"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// IssueJWT issues a fresh access JWT and a rotating opaque refresh token.
|
|
// The refresh token is bound to a chain via Token.ChainID; rotation
|
|
// preserves that chain so reuse-detection can revoke the whole family.
|
|
// chainStartedAt is stamped on this row and copied forward on every
|
|
// rotation so RefreshJWT can enforce RefreshChainAbsoluteTTL in O(1).
|
|
func (a *Auth) IssueJWT(ctx context.Context, userID uuid.UUID) (access, refresh string, err error) {
|
|
const op = "authkit.Auth.IssueJWT"
|
|
u, err := a.storeGetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
access, err = a.signAccessToken(u.ID, u.SessionVersion)
|
|
if err != nil {
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
refresh, err = a.mintRefreshToken(ctx, u.ID, uuid.NewString(), a.now())
|
|
if err != nil {
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
return access, refresh, nil
|
|
}
|
|
|
|
// AuthenticateJWT validates the access JWT, cross-checks the user's
|
|
// session_version (instant revocation), and resolves a Principal.
|
|
func (a *Auth) AuthenticateJWT(ctx context.Context, access string) (*Principal, error) {
|
|
const op = "authkit.Auth.AuthenticateJWT"
|
|
claims, err := a.parseAccessToken(access)
|
|
if err != nil {
|
|
return nil, errx.Wrap(op, err)
|
|
}
|
|
uid, err := uuid.Parse(claims.Subject)
|
|
if err != nil {
|
|
return nil, errx.Wrap(op, ErrTokenInvalid)
|
|
}
|
|
u, err := a.storeGetUserByID(ctx, uid)
|
|
if err != nil {
|
|
if errors.Is(err, ErrUserNotFound) {
|
|
return nil, errx.Wrap(op, ErrTokenInvalid)
|
|
}
|
|
return nil, errx.Wrap(op, err)
|
|
}
|
|
if u.SessionVersion != claims.SessionVersion {
|
|
return nil, errx.Wrap(op, ErrTokenInvalid)
|
|
}
|
|
roles, perms, err := a.resolveRolesAndPermissions(ctx, u.ID)
|
|
if err != nil {
|
|
return nil, errx.Wrap(op, err)
|
|
}
|
|
return &Principal{
|
|
UserID: u.ID,
|
|
Method: AuthMethodJWT,
|
|
Roles: roles,
|
|
Permissions: perms,
|
|
IssuedAt: claims.IssuedAt.Time,
|
|
ExpiresAt: claims.ExpiresAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
// RefreshJWT consumes the presented refresh token and mints a new
|
|
// access+refresh pair. Reuse of an already-consumed refresh token deletes
|
|
// the entire chain (logout-everywhere on that device family) and returns
|
|
// ErrTokenReused. The chain itself is capped at RefreshChainAbsoluteTTL
|
|
// from chain_started_at — past that, refresh fails with ErrTokenInvalid
|
|
// and the chain is deleted, forcing the user to re-authenticate.
|
|
func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access, refresh string, err error) {
|
|
const op = "authkit.Auth.RefreshJWT"
|
|
hash, ok := ParseOpaqueSecret(prefixRefresh, plaintextRefresh)
|
|
if !ok {
|
|
return "", "", errx.Wrap(op, ErrTokenInvalid)
|
|
}
|
|
now := a.now()
|
|
|
|
consumed, err := a.storeConsumeToken(ctx, TokenRefresh, hash, now)
|
|
if err != nil {
|
|
// Differentiate plain-invalid (never existed / expired) from reuse
|
|
// (existed, already consumed). Existence-with-consumed is the
|
|
// reuse signal.
|
|
if errors.Is(err, ErrTokenInvalid) {
|
|
if existing, gerr := a.storeGetToken(ctx, TokenRefresh, hash); gerr == nil && existing.ConsumedAt != nil {
|
|
if existing.ChainID != nil && *existing.ChainID != "" {
|
|
_, _ = a.storeDeleteByChain(ctx, *existing.ChainID)
|
|
}
|
|
return "", "", errx.Wrap(op, ErrTokenReused)
|
|
}
|
|
}
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
|
|
// Enforce the absolute chain cap. If the chain is older than the
|
|
// configured ceiling, kill the whole chain rather than minting a
|
|
// successor — re-authentication is required.
|
|
chainStartedAt := now
|
|
if consumed.ChainStartedAt != nil {
|
|
chainStartedAt = *consumed.ChainStartedAt
|
|
}
|
|
if now.After(chainStartedAt.Add(a.cfg.RefreshChainAbsoluteTTL)) {
|
|
if consumed.ChainID != nil && *consumed.ChainID != "" {
|
|
_, _ = a.storeDeleteByChain(ctx, *consumed.ChainID)
|
|
}
|
|
return "", "", errx.Wrap(op, ErrTokenInvalid)
|
|
}
|
|
|
|
var chainID string
|
|
if consumed.ChainID != nil {
|
|
chainID = *consumed.ChainID
|
|
}
|
|
if chainID == "" {
|
|
// Defensive: every refresh token should be chain-bound. Fall back
|
|
// to a fresh chain rather than throwing on missing metadata.
|
|
chainID = uuid.NewString()
|
|
chainStartedAt = now
|
|
}
|
|
|
|
access, err = a.signAccessToken(consumed.UserID, a.userSessionVersion(ctx, consumed.UserID))
|
|
if err != nil {
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
refresh, err = a.mintRefreshToken(ctx, consumed.UserID, chainID, chainStartedAt)
|
|
if err != nil {
|
|
return "", "", errx.Wrap(op, err)
|
|
}
|
|
return access, refresh, nil
|
|
}
|
|
|
|
func (a *Auth) mintRefreshToken(ctx context.Context, userID uuid.UUID, chainID string, chainStartedAt time.Time) (string, error) {
|
|
const op = "authkit.Auth.mintRefreshToken"
|
|
plaintext, hash, err := MintOpaqueSecret(a.cfg.Random, prefixRefresh)
|
|
if err != nil {
|
|
return "", errx.Wrap(op, err)
|
|
}
|
|
now := a.now()
|
|
t := &Token{
|
|
Hash: hash,
|
|
Kind: TokenRefresh,
|
|
UserID: userID,
|
|
ChainID: &chainID,
|
|
ChainStartedAt: &chainStartedAt,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(a.cfg.RefreshTokenTTL),
|
|
}
|
|
if err := a.storeCreateToken(ctx, t); err != nil {
|
|
return "", errx.Wrap(op, err)
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
// userSessionVersion fetches the current session_version. Errors collapse
|
|
// to 0 — a stale token will fail AuthenticateJWT cleanly anyway.
|
|
func (a *Auth) userSessionVersion(ctx context.Context, userID uuid.UUID) int {
|
|
if u, err := a.storeGetUserByID(ctx, userID); err == nil {
|
|
return u.SessionVersion
|
|
}
|
|
return 0
|
|
}
|