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:
parent
9aae7b1c12
commit
4942e4dbdc
16 changed files with 664 additions and 24 deletions
167
service_service_key_test.go
Normal file
167
service_service_key_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue