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>
114 lines
3.1 KiB
Go
114 lines
3.1 KiB
Go
package authkit
|
|
|
|
import (
|
|
"net/netip"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// User is the canonical account record. Password hash is empty (and stored
|
|
// NULL in the DB) when no credential has been set — accounts created via
|
|
// invite or magic-link-only flows live in this state until SetPassword runs.
|
|
type User struct {
|
|
ID uuid.UUID
|
|
Email string
|
|
EmailNormalized string
|
|
EmailVerifiedAt *time.Time
|
|
PasswordHash string
|
|
SessionVersion int
|
|
LastLoginAt *time.Time
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Session is an opaque server-side credential bound to one user.
|
|
type Session struct {
|
|
IDHash []byte
|
|
UserID uuid.UUID
|
|
UserAgent string
|
|
IP netip.Addr
|
|
CreatedAt time.Time
|
|
LastSeenAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// TokenKind enumerates the single-use credentials persisted in authkit_tokens.
|
|
type TokenKind string
|
|
|
|
const (
|
|
TokenEmailVerify TokenKind = "email_verify"
|
|
TokenPasswordReset TokenKind = "password_reset"
|
|
TokenMagicLink TokenKind = "magic_link"
|
|
TokenEmailOTP TokenKind = "email_otp"
|
|
TokenRefresh TokenKind = "refresh"
|
|
)
|
|
|
|
// Token is one row in authkit_tokens. AttemptsRemaining is non-nil only for
|
|
// tokens that allow retry on incorrect input (email OTPs); other kinds are
|
|
// strictly one-shot via ConsumeToken. ChainStartedAt is non-nil only for
|
|
// refresh-token rows; copied forward on every rotation so the absolute-cap
|
|
// check in RefreshJWT is O(1).
|
|
type Token struct {
|
|
Hash []byte
|
|
Kind TokenKind
|
|
UserID uuid.UUID
|
|
ChainID *string
|
|
ChainStartedAt *time.Time
|
|
ConsumedAt *time.Time
|
|
AttemptsRemaining *int
|
|
CreatedAt time.Time
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// ServiceKey is a machine credential. It carries no identity — service tokens
|
|
// are produced by applications for outbound API access or inbound automation,
|
|
// and authorize via Abilities resolved through the join table.
|
|
type ServiceKey struct {
|
|
IDHash []byte
|
|
Name string
|
|
Abilities []string
|
|
LastUsedAt *time.Time
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
// HasAbility reports whether the service key carries the named ability slug.
|
|
func (k *ServiceKey) HasAbility(slug string) bool {
|
|
for _, a := range k.Abilities {
|
|
if a == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Role groups permissions for assignment to users. Slug is the immutable
|
|
// business key; Label is an optional human-readable name.
|
|
type Role struct {
|
|
ID uuid.UUID
|
|
Slug string
|
|
Label string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Permission is a unit of authorization. Granted to users either through a
|
|
// role or directly via authkit_user_permissions.
|
|
type Permission struct {
|
|
ID uuid.UUID
|
|
Slug string
|
|
Label string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Ability is a unit of authorization for service tokens. Abilities are a
|
|
// separate vocabulary from Permissions because they target machines, not
|
|
// users — keep them distinct so middleware predicates remain clear about
|
|
// which subject they're authorizing.
|
|
type Ability struct {
|
|
ID uuid.UUID
|
|
Slug string
|
|
Label string
|
|
CreatedAt time.Time
|
|
}
|