Add owner-agnostic service tokens

Introduces ServiceKey, a parallel primitive to APIKey for server-to-server
auth where the owner is not an authkit user (e.g. an application or tenant
row the consumer manages). owner_id has no FK and no RBAC linkage; cascade
on owner-delete is the consumer's responsibility. AuthenticateServiceKey
returns *ServiceKey directly rather than *Principal since service tokens
have no user.

Also exports MintOpaqueSecret / HashOpaqueSecret / ParseOpaqueSecret so
both API-key and service-key code share one mint/parse implementation
instead of duplicating it. Deps.ServiceKeys is required (panics in New
if nil) — existing call sites must add ServiceKeys: stores.ServiceKeys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
juancwu 2026-04-26 19:54:26 +00:00
commit 4942e4dbdc
16 changed files with 664 additions and 24 deletions

View file

@ -19,16 +19,19 @@ const (
prefixSession = "sess"
prefixRefresh = "rfr"
prefixAPIKey = "ak"
prefixServiceKey = "sk"
prefixEmailVerify = "evr"
prefixPasswordRset = "pwr"
prefixMagicLink = "mlnk"
)
// mintSecret generates a new opaque secret of the given prefix kind and
// returns the plaintext (to be returned to the user, never stored) and the
// SHA-256 lookup hash (to be stored).
func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, err error) {
const op = "authkit.mintSecret"
// MintOpaqueSecret generates a fresh opaque secret with the given prefix.
// Returns the plaintext (show once, never persist) and the SHA-256 lookup
// hash. A nil rng falls back to crypto/rand.Reader. Exposed so consumers
// building bespoke storage can produce secrets in the same shape authkit
// uses internally.
func MintOpaqueSecret(rng io.Reader, prefix string) (plaintext string, hash []byte, err error) {
const op = "authkit.MintOpaqueSecret"
if rng == nil {
rng = rand.Reader
}
@ -38,27 +41,40 @@ func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, er
}
body := base64.RawURLEncoding.EncodeToString(buf)
plaintext = prefix + "_" + body
hash = hashSecret(plaintext)
hash = HashOpaqueSecret(plaintext)
return plaintext, hash, nil
}
// hashSecret returns sha256(plaintext) — the lookup key for opaque secrets.
// Plaintexts have full entropy from a CSPRNG so a plain hash is sufficient
// (no per-record salt needed; the random body is the salt).
func hashSecret(plaintext string) []byte {
// HashOpaqueSecret returns sha256(plaintext) — the lookup key for opaque
// secrets. Plaintexts have full entropy from a CSPRNG so a plain hash is
// sufficient (no per-record salt needed; the random body is the salt).
func HashOpaqueSecret(plaintext string) []byte {
sum := sha256.Sum256([]byte(plaintext))
return sum[:]
}
// parseSecret validates that a plaintext starts with the expected prefix
// and returns the lookup hash. The prefix check is constant-time relative
// to a fixed-length comparison.
func parseSecret(prefix, plaintext string) (hash []byte, ok bool) {
// ParseOpaqueSecret validates that a plaintext begins with the expected
// prefix and returns the lookup hash. Returns ok=false on prefix mismatch.
func ParseOpaqueSecret(prefix, plaintext string) (hash []byte, ok bool) {
want := prefix + "_"
if !strings.HasPrefix(plaintext, want) {
return nil, false
}
return hashSecret(plaintext), true
return HashOpaqueSecret(plaintext), true
}
// mintSecret is the internal entry point; existing callers pass prefix
// first to match call-site readability ("mint a token of kind X").
func mintSecret(prefix string, rng io.Reader) (plaintext string, hash []byte, err error) {
return MintOpaqueSecret(rng, prefix)
}
func hashSecret(plaintext string) []byte {
return HashOpaqueSecret(plaintext)
}
func parseSecret(prefix, plaintext string) (hash []byte, ok bool) {
return ParseOpaqueSecret(prefix, plaintext)
}
// constantTimeEqual is a thin wrapper for readability at call sites.