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

167
service_service_key_test.go Normal file
View file

@ -0,0 +1,167 @@
package authkit
import (
"context"
"encoding/base64"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
func TestServiceKeyRoundtrip(t *testing.T) {
a := newTestAuth(t)
appID := uuid.New()
plaintext, k, err := a.IssueServiceKey(context.Background(),
"application", appID, "events-ingest",
[]string{"events:write", "events:read"}, nil)
if err != nil {
t.Fatalf("IssueServiceKey: %v", err)
}
if plaintext == "" || k == nil {
t.Fatalf("missing plaintext or key")
}
got, err := a.AuthenticateServiceKey(context.Background(), plaintext)
if err != nil {
t.Fatalf("AuthenticateServiceKey: %v", err)
}
if got.OwnerKind != "application" || got.OwnerID != appID {
t.Fatalf("owner mismatch: kind=%q id=%v", got.OwnerKind, got.OwnerID)
}
if got.Name != "events-ingest" {
t.Fatalf("name mismatch: %q", got.Name)
}
if len(got.Abilities) != 2 || got.Abilities[0] != "events:write" || got.Abilities[1] != "events:read" {
t.Fatalf("abilities mismatch: %+v", got.Abilities)
}
got.Abilities[0] = "tampered"
again, err := a.AuthenticateServiceKey(context.Background(), plaintext)
if err != nil {
t.Fatalf("AuthenticateServiceKey (re-auth): %v", err)
}
if again.Abilities[0] != "events:write" {
t.Fatalf("returned slice was not deep-copied; saw mutation: %+v", again.Abilities)
}
}
func TestServiceKeyPlaintextShape(t *testing.T) {
a := newTestAuth(t)
plaintext, _, err := a.IssueServiceKey(context.Background(),
"application", uuid.New(), "name", nil, nil)
if err != nil {
t.Fatalf("IssueServiceKey: %v", err)
}
if !strings.HasPrefix(plaintext, "sk_") {
t.Fatalf("plaintext missing sk_ prefix: %q", plaintext)
}
body := strings.TrimPrefix(plaintext, "sk_")
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
t.Fatalf("base64 decode: %v", err)
}
if len(raw) != 32 {
t.Fatalf("body decoded to %d bytes, want 32", len(raw))
}
}
func TestServiceKeyWrongPrefix(t *testing.T) {
a := newTestAuth(t)
_, err := a.AuthenticateServiceKey(context.Background(), "ak_not-a-service-key")
if !errors.Is(err, ErrServiceKeyInvalid) {
t.Fatalf("expected ErrServiceKeyInvalid for wrong prefix, got %v", err)
}
}
func TestServiceKeyAfterRevoke(t *testing.T) {
a := newTestAuth(t)
plaintext, _, err := a.IssueServiceKey(context.Background(),
"application", uuid.New(), "ci", nil, nil)
if err != nil {
t.Fatalf("IssueServiceKey: %v", err)
}
if err := a.RevokeServiceKey(context.Background(), plaintext); err != nil {
t.Fatalf("RevokeServiceKey: %v", err)
}
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); !errors.Is(err, ErrServiceKeyInvalid) {
t.Fatalf("expected ErrServiceKeyInvalid post-revoke, got %v", err)
}
}
func TestServiceKeyAfterExpiry(t *testing.T) {
a := newTestAuth(t)
now := time.Now().UTC()
a.cfg.Clock = func() time.Time { return now }
ttl := time.Minute
plaintext, _, err := a.IssueServiceKey(context.Background(),
"application", uuid.New(), "ephemeral", nil, &ttl)
if err != nil {
t.Fatalf("IssueServiceKey: %v", err)
}
a.cfg.Clock = func() time.Time { return now.Add(2 * time.Minute) }
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); !errors.Is(err, ErrServiceKeyInvalid) {
t.Fatalf("expected ErrServiceKeyInvalid post-expiry, got %v", err)
}
}
func TestServiceKeyListByOwner(t *testing.T) {
a := newTestAuth(t)
appA := uuid.New()
appB := uuid.New()
for i := 0; i < 2; i++ {
if _, _, err := a.IssueServiceKey(context.Background(), "application", appA, "k", nil, nil); err != nil {
t.Fatalf("Issue appA #%d: %v", i, err)
}
}
if _, _, err := a.IssueServiceKey(context.Background(), "application", appB, "k", nil, nil); err != nil {
t.Fatalf("Issue appB: %v", err)
}
gotA, err := a.ListServiceKeys(context.Background(), "application", appA)
if err != nil {
t.Fatalf("ListServiceKeys appA: %v", err)
}
if len(gotA) != 2 {
t.Fatalf("ListServiceKeys appA = %d keys, want 2", len(gotA))
}
gotB, err := a.ListServiceKeys(context.Background(), "application", appB)
if err != nil {
t.Fatalf("ListServiceKeys appB: %v", err)
}
if len(gotB) != 1 {
t.Fatalf("ListServiceKeys appB = %d keys, want 1", len(gotB))
}
gotTenantA, err := a.ListServiceKeys(context.Background(), "tenant", appA)
if err != nil {
t.Fatalf("ListServiceKeys tenant/appA: %v", err)
}
if len(gotTenantA) != 0 {
t.Fatalf("ListServiceKeys tenant/appA = %d, want 0 (different owner_kind)", len(gotTenantA))
}
}
func TestServiceKeyTouchUpdatesLastUsedAt(t *testing.T) {
a := newTestAuth(t)
appID := uuid.New()
plaintext, _, err := a.IssueServiceKey(context.Background(), "application", appID, "k", nil, nil)
if err != nil {
t.Fatalf("IssueServiceKey: %v", err)
}
keys, err := a.ListServiceKeys(context.Background(), "application", appID)
if err != nil || len(keys) != 1 {
t.Fatalf("pre-touch list: err=%v len=%d", err, len(keys))
}
if keys[0].LastUsedAt != nil {
t.Fatalf("expected LastUsedAt=nil before authenticate, got %v", *keys[0].LastUsedAt)
}
if _, err := a.AuthenticateServiceKey(context.Background(), plaintext); err != nil {
t.Fatalf("AuthenticateServiceKey: %v", err)
}
keys, err = a.ListServiceKeys(context.Background(), "application", appID)
if err != nil || len(keys) != 1 {
t.Fatalf("post-touch list: err=%v len=%d", err, len(keys))
}
if keys[0].LastUsedAt == nil {
t.Fatalf("expected LastUsedAt to be set after authenticate")
}
}