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
60
README.md
60
README.md
|
|
@ -38,6 +38,7 @@ PostgreSQL 12+ is sufficient — the schema avoids `gen_random_uuid()` and
|
|||
**Authorization**
|
||||
- Roles and permissions with many-to-many wiring
|
||||
- API keys with custom abilities for per-endpoint scoping
|
||||
- Owner-agnostic service tokens for server-to-server auth (no FK on owner)
|
||||
- A unified `Principal` type so middleware works the same regardless of which
|
||||
authentication method ran
|
||||
|
||||
|
|
@ -117,6 +118,7 @@ auth := authkit.New(authkit.Deps{
|
|||
Sessions: stores.Sessions,
|
||||
Tokens: stores.Tokens,
|
||||
APIKeys: stores.APIKeys,
|
||||
ServiceKeys: stores.ServiceKeys,
|
||||
Roles: stores.Roles,
|
||||
Permissions: stores.Permissions,
|
||||
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
||||
|
|
@ -130,8 +132,9 @@ auth := authkit.New(authkit.Deps{
|
|||
|
||||
`Config` zero values fall back to sensible defaults (24h idle / 30d absolute
|
||||
session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h
|
||||
password-reset, 15m magic-link). `JWTSecret` and the seven `Deps` fields are
|
||||
required; `New` panics on a misconfiguration.
|
||||
password-reset, 15m magic-link). `JWTSecret` and all eight `Deps` fields
|
||||
(including the new `ServiceKeys` field added in v0.2.0) are required; `New`
|
||||
panics on a misconfiguration.
|
||||
|
||||
### 3. Use the service
|
||||
|
||||
|
|
@ -148,10 +151,18 @@ http.SetCookie(w, auth.SessionCookie(plaintext, sess.ExpiresAt))
|
|||
access, refresh, err := auth.IssueJWT(ctx, u.ID)
|
||||
access, refresh, err = auth.RefreshJWT(ctx, refresh) // old refresh is consumed
|
||||
|
||||
// API key with abilities
|
||||
// API key with abilities (user-owned)
|
||||
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
|
||||
[]string{"billing:read", "users:list"}, nil)
|
||||
|
||||
// Service token (owner-agnostic; ownerKind labels the namespace)
|
||||
plaintext, sk, err := auth.IssueServiceKey(ctx,
|
||||
"application", appID, "events-ingest",
|
||||
[]string{"events:write"}, nil)
|
||||
got, err := auth.AuthenticateServiceKey(ctx, plaintext)
|
||||
// got.OwnerKind == "application"; got.OwnerID == appID
|
||||
err = auth.RevokeServiceKey(ctx, plaintext)
|
||||
|
||||
// Email verification + password reset + magic link
|
||||
tok, err := auth.RequestEmailVerification(ctx, u.ID)
|
||||
_, err = auth.ConfirmEmail(ctx, tok)
|
||||
|
|
@ -233,8 +244,8 @@ each table. Adding column overrides later is purely additive.
|
|||
|
||||
### Secret token format
|
||||
|
||||
Sessions, refresh tokens, API keys, email-verify tokens, password-reset tokens,
|
||||
and magic-link tokens all share one format:
|
||||
Sessions, refresh tokens, API keys, service tokens, email-verify tokens,
|
||||
password-reset tokens, and magic-link tokens all share one format:
|
||||
|
||||
```
|
||||
plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
|
||||
|
|
@ -243,7 +254,44 @@ lookup = sha256(plaintext)
|
|||
|
||||
Plaintext is returned to the caller exactly once and never persisted; the
|
||||
SHA-256 is the database lookup key. Random bytes come from `crypto/rand` (or
|
||||
`Config.Random` for tests).
|
||||
`Config.Random` for tests). The mint/parse/hash helpers are exported as
|
||||
`MintOpaqueSecret`, `ParseOpaqueSecret`, and `HashOpaqueSecret` for callers
|
||||
building bespoke token storage on top of the same shape.
|
||||
|
||||
### Service tokens vs. API keys
|
||||
|
||||
Both produce opaque `<prefix>_<base64>` secrets, but they target different
|
||||
use cases.
|
||||
|
||||
`IssueAPIKey` is for **user-owned credentials**. The owner is a row in
|
||||
`authkit_users`; deleting that user cascades to the key (`ON DELETE CASCADE`),
|
||||
and `AuthenticateAPIKey` returns a `*Principal` carrying the user's roles
|
||||
and permissions resolved through RBAC.
|
||||
|
||||
`IssueServiceKey` is for **server-to-server credentials** whose owner is
|
||||
something authkit knows nothing about — an `application` row, a `tenant`,
|
||||
whatever. `OwnerKind` labels the namespace and `OwnerID` identifies the
|
||||
entity within it; the database column has **no foreign key** on purpose, so
|
||||
cascade-on-delete is the consumer's responsibility. `AuthenticateServiceKey`
|
||||
returns the `*ServiceKey` directly (no `*Principal`, no role/permission
|
||||
resolution): service tokens have no user, so the `Principal` abstraction
|
||||
does not fit. Abilities (`[]string`) on a service token are free-form —
|
||||
authkit does not link them to `authkit_roles` or `authkit_permissions`.
|
||||
|
||||
```go
|
||||
plaintext, key, err := auth.IssueServiceKey(ctx,
|
||||
"application", appID, "events-ingest",
|
||||
[]string{"events:write"}, nil)
|
||||
|
||||
k, err := auth.AuthenticateServiceKey(ctx, plaintext)
|
||||
// k.OwnerKind == "application"; k.OwnerID == appID
|
||||
|
||||
err = auth.RevokeServiceKey(ctx, plaintext)
|
||||
```
|
||||
|
||||
When a consumer-owned entity (an application, a tenant) is deleted, the
|
||||
consumer must revoke the associated service tokens itself — typically by
|
||||
iterating `ListServiceKeys(ctx, ownerKind, ownerID)`.
|
||||
|
||||
### JWT revocation
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type Deps struct {
|
|||
Sessions SessionStore
|
||||
Tokens TokenStore
|
||||
APIKeys APIKeyStore
|
||||
ServiceKeys ServiceKeyStore
|
||||
Roles RoleStore
|
||||
Permissions PermissionStore
|
||||
Hasher Hasher
|
||||
|
|
@ -69,8 +70,8 @@ type Auth struct {
|
|||
// returning an error — these are programmer errors, not runtime ones.
|
||||
func New(deps Deps, cfg Config) *Auth {
|
||||
if deps.Users == nil || deps.Sessions == nil || deps.Tokens == nil ||
|
||||
deps.APIKeys == nil || deps.Roles == nil || deps.Permissions == nil ||
|
||||
deps.Hasher == nil {
|
||||
deps.APIKeys == nil || deps.ServiceKeys == nil || deps.Roles == nil ||
|
||||
deps.Permissions == nil || deps.Hasher == nil {
|
||||
panic(errx.New("authkit.New", "all Deps fields are required"))
|
||||
}
|
||||
if len(cfg.JWTSecret) == 0 {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ var (
|
|||
ErrTokenReused = errors.New("authkit: token reuse detected")
|
||||
ErrSessionInvalid = errors.New("authkit: invalid or expired session")
|
||||
ErrAPIKeyInvalid = errors.New("authkit: invalid or expired api key")
|
||||
ErrServiceKeyInvalid = errors.New("authkit: invalid or expired service key")
|
||||
ErrPermissionDenied = errors.New("authkit: permission denied")
|
||||
ErrRoleNotFound = errors.New("authkit: role not found")
|
||||
ErrPermissionNotFound = errors.New("authkit: permission not found")
|
||||
|
|
|
|||
|
|
@ -331,6 +331,68 @@ func (s *memAPIKeyStore) RevokeAPIKeysByOwner(_ context.Context, owner uuid.UUID
|
|||
return nil
|
||||
}
|
||||
|
||||
type memServiceKeyStore struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*ServiceKey
|
||||
}
|
||||
|
||||
func newMemServiceKeyStore() *memServiceKeyStore {
|
||||
return &memServiceKeyStore{m: map[string]*ServiceKey{}}
|
||||
}
|
||||
func (s *memServiceKeyStore) CreateServiceKey(_ context.Context, k *ServiceKey) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cp := *k
|
||||
cp.Abilities = append([]string(nil), k.Abilities...)
|
||||
s.m[string(k.IDHash)] = &cp
|
||||
return nil
|
||||
}
|
||||
func (s *memServiceKeyStore) GetServiceKey(_ context.Context, h []byte) (*ServiceKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
k, ok := s.m[string(h)]
|
||||
if !ok {
|
||||
return nil, ErrServiceKeyInvalid
|
||||
}
|
||||
cp := *k
|
||||
cp.Abilities = append([]string(nil), k.Abilities...)
|
||||
return &cp, nil
|
||||
}
|
||||
func (s *memServiceKeyStore) ListServiceKeysByOwner(_ context.Context, ownerKind string, owner uuid.UUID) ([]*ServiceKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []*ServiceKey
|
||||
for _, k := range s.m {
|
||||
if k.OwnerKind == ownerKind && k.OwnerID == owner {
|
||||
cp := *k
|
||||
cp.Abilities = append([]string(nil), k.Abilities...)
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *memServiceKeyStore) TouchServiceKey(_ context.Context, h []byte, at time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if k, ok := s.m[string(h)]; ok {
|
||||
k.LastUsedAt = &at
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *memServiceKeyStore) RevokeServiceKey(_ context.Context, h []byte, at time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
k, ok := s.m[string(h)]
|
||||
if !ok {
|
||||
return ErrServiceKeyInvalid
|
||||
}
|
||||
if k.RevokedAt != nil {
|
||||
return ErrServiceKeyInvalid
|
||||
}
|
||||
k.RevokedAt = &at
|
||||
return nil
|
||||
}
|
||||
|
||||
type memRoleStore struct {
|
||||
mu sync.Mutex
|
||||
roles map[uuid.UUID]*Role
|
||||
|
|
@ -602,6 +664,7 @@ func newTestAuth(t interface{ Helper() }) *Auth {
|
|||
Sessions: newMemSessionStore(),
|
||||
Tokens: newMemTokenStore(),
|
||||
APIKeys: newMemAPIKeyStore(),
|
||||
ServiceKeys: newMemServiceKeyStore(),
|
||||
Roles: roles,
|
||||
Permissions: newMemPermStore(roles),
|
||||
Hasher: stubHasher{},
|
||||
|
|
|
|||
16
models.go
16
models.go
|
|
@ -60,6 +60,22 @@ type APIKey struct {
|
|||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
// ServiceKey is an owner-agnostic API key for server-to-server auth. Unlike
|
||||
// APIKey, OwnerID is not constrained to authkit_users — OwnerKind labels the
|
||||
// owner namespace (e.g. "application", "tenant") and consumers manage their
|
||||
// own cascade-on-delete.
|
||||
type ServiceKey struct {
|
||||
IDHash []byte
|
||||
OwnerID uuid.UUID
|
||||
OwnerKind string
|
||||
Name string
|
||||
Abilities []string
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
type Role struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
|
|
|
|||
92
service_service_key.go
Normal file
92
service_service_key.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package authkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/errx"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// IssueServiceKey mints a fresh owner-agnostic service token. ownerKind is a
|
||||
// consumer-defined namespace label (e.g. "application", "tenant") and ownerID
|
||||
// is the owning entity's id; authkit makes no assumption about either. The
|
||||
// plaintext is returned (show-once) and the SHA-256 lookup hash is stored.
|
||||
// Pass ttl=nil for a non-expiring key.
|
||||
func (a *Auth) IssueServiceKey(ctx context.Context, ownerKind string, ownerID uuid.UUID, name string, abilities []string, ttl *time.Duration) (string, *ServiceKey, error) {
|
||||
const op = "authkit.Auth.IssueServiceKey"
|
||||
plaintext, hash, err := MintOpaqueSecret(a.cfg.Random, prefixServiceKey)
|
||||
if err != nil {
|
||||
return "", nil, errx.Wrap(op, err)
|
||||
}
|
||||
now := a.now()
|
||||
k := &ServiceKey{
|
||||
IDHash: hash,
|
||||
OwnerID: ownerID,
|
||||
OwnerKind: ownerKind,
|
||||
Name: name,
|
||||
Abilities: append([]string(nil), abilities...),
|
||||
CreatedAt: now,
|
||||
}
|
||||
if ttl != nil {
|
||||
exp := now.Add(*ttl)
|
||||
k.ExpiresAt = &exp
|
||||
}
|
||||
if err := a.deps.ServiceKeys.CreateServiceKey(ctx, k); err != nil {
|
||||
return "", nil, errx.Wrap(op, err)
|
||||
}
|
||||
return plaintext, k, nil
|
||||
}
|
||||
|
||||
// AuthenticateServiceKey validates a service token, touches last_used_at
|
||||
// (best-effort), and returns the stored *ServiceKey. Unlike API keys, no
|
||||
// Principal is returned — service tokens have no owning user, so the
|
||||
// Principal abstraction does not fit. Consumers needing a Principal can
|
||||
// build one from the returned key.
|
||||
func (a *Auth) AuthenticateServiceKey(ctx context.Context, plaintext string) (*ServiceKey, error) {
|
||||
const op = "authkit.Auth.AuthenticateServiceKey"
|
||||
hash, ok := ParseOpaqueSecret(prefixServiceKey, plaintext)
|
||||
if !ok {
|
||||
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||
}
|
||||
k, err := a.deps.ServiceKeys.GetServiceKey(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
now := a.now()
|
||||
if k.RevokedAt != nil {
|
||||
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||
}
|
||||
if k.ExpiresAt != nil && !k.ExpiresAt.After(now) {
|
||||
return nil, errx.Wrap(op, ErrServiceKeyInvalid)
|
||||
}
|
||||
_ = a.deps.ServiceKeys.TouchServiceKey(ctx, hash, now)
|
||||
out := *k
|
||||
out.Abilities = append([]string(nil), k.Abilities...)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RevokeServiceKey marks a service token revoked. Idempotent on
|
||||
// already-revoked keys.
|
||||
func (a *Auth) RevokeServiceKey(ctx context.Context, plaintext string) error {
|
||||
const op = "authkit.Auth.RevokeServiceKey"
|
||||
hash, ok := ParseOpaqueSecret(prefixServiceKey, plaintext)
|
||||
if !ok {
|
||||
return errx.Wrap(op, ErrServiceKeyInvalid)
|
||||
}
|
||||
if err := a.deps.ServiceKeys.RevokeServiceKey(ctx, hash, a.now()); err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListServiceKeys returns every service token issued for the given
|
||||
// (ownerKind, ownerID) pair, including revoked and expired keys.
|
||||
func (a *Auth) ListServiceKeys(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*ServiceKey, error) {
|
||||
const op = "authkit.Auth.ListServiceKeys"
|
||||
out, err := a.deps.ServiceKeys.ListServiceKeysByOwner(ctx, ownerKind, ownerID)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +89,13 @@ type Queries struct {
|
|||
RevokeAPIKey string
|
||||
RevokeAPIKeysByOwner string
|
||||
|
||||
// service keys
|
||||
CreateServiceKey string
|
||||
GetServiceKey string
|
||||
ListServiceKeysByOwner string
|
||||
TouchServiceKey string
|
||||
RevokeServiceKey string
|
||||
|
||||
// roles
|
||||
CreateRole string
|
||||
GetRoleByID string
|
||||
|
|
|
|||
27
sqlstore/dialect/postgres/migrations/0002_service_keys.sql
Normal file
27
sqlstore/dialect/postgres/migrations/0002_service_keys.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- 0002_service_keys.sql
|
||||
-- Adds owner-agnostic service tokens. Unlike authkit_api_keys, owner_id is
|
||||
-- intentionally NOT FK-constrained: consumers manage their own cascades, and
|
||||
-- authkit has no opinion on what "owner" means here (application id, tenant
|
||||
-- id, etc.).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS authkit_service_keys (
|
||||
id_hash BYTEA PRIMARY KEY,
|
||||
owner_id UUID NOT NULL,
|
||||
owner_kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
abilities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS authkit_service_keys_owner_idx
|
||||
ON authkit_service_keys(owner_kind, owner_id);
|
||||
|
||||
INSERT INTO authkit_schema_migrations (version, applied_at) VALUES ('0002_service_keys', now())
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -148,6 +148,19 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries {
|
|||
RevokeAPIKey: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL`,
|
||||
RevokeAPIKeysByOwner: `UPDATE ` + t.APIKeys + ` SET revoked_at = ? WHERE owner_id = ? AND revoked_at IS NULL`,
|
||||
|
||||
// service keys
|
||||
CreateServiceKey: `INSERT INTO ` + t.ServiceKeys + `
|
||||
(id_hash, owner_id, owner_kind, name, abilities, last_used_at, created_at, expires_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
GetServiceKey: `SELECT id_hash, owner_id, owner_kind, name, abilities, last_used_at,
|
||||
created_at, expires_at, revoked_at
|
||||
FROM ` + t.ServiceKeys + ` WHERE id_hash = ?`,
|
||||
ListServiceKeysByOwner: `SELECT id_hash, owner_id, owner_kind, name, abilities, last_used_at,
|
||||
created_at, expires_at, revoked_at
|
||||
FROM ` + t.ServiceKeys + ` WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at DESC`,
|
||||
TouchServiceKey: `UPDATE ` + t.ServiceKeys + ` SET last_used_at = ? WHERE id_hash = ?`,
|
||||
RevokeServiceKey: `UPDATE ` + t.ServiceKeys + ` SET revoked_at = ? WHERE id_hash = ? AND revoked_at IS NULL`,
|
||||
|
||||
// roles
|
||||
CreateRole: `INSERT INTO ` + t.Roles + ` (id, name, description, created_at) VALUES (?, ?, ?, ?)`,
|
||||
GetRoleByID: `SELECT id, name, description, created_at FROM ` + t.Roles + ` WHERE id = ?`,
|
||||
|
|
@ -222,6 +235,12 @@ func (Dialect) BuildQueries(s sqlstore.Schema) sqlstore.Queries {
|
|||
q.RevokeAPIKey = rebind(q.RevokeAPIKey)
|
||||
q.RevokeAPIKeysByOwner = rebind(q.RevokeAPIKeysByOwner)
|
||||
|
||||
q.CreateServiceKey = rebind(q.CreateServiceKey)
|
||||
q.GetServiceKey = rebind(q.GetServiceKey)
|
||||
q.ListServiceKeysByOwner = rebind(q.ListServiceKeysByOwner)
|
||||
q.TouchServiceKey = rebind(q.TouchServiceKey)
|
||||
q.RevokeServiceKey = rebind(q.RevokeServiceKey)
|
||||
|
||||
q.CreateRole = rebind(q.CreateRole)
|
||||
q.GetRoleByID = rebind(q.GetRoleByID)
|
||||
q.GetRoleByName = rebind(q.GetRoleByName)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type Tables struct {
|
|||
Sessions string
|
||||
Tokens string
|
||||
APIKeys string
|
||||
ServiceKeys string
|
||||
Roles string
|
||||
Permissions string
|
||||
UserRoles string
|
||||
|
|
@ -37,6 +38,7 @@ func DefaultSchema() Schema {
|
|||
Sessions: "authkit_sessions",
|
||||
Tokens: "authkit_tokens",
|
||||
APIKeys: "authkit_api_keys",
|
||||
ServiceKeys: "authkit_service_keys",
|
||||
Roles: "authkit_roles",
|
||||
Permissions: "authkit_permissions",
|
||||
UserRoles: "authkit_user_roles",
|
||||
|
|
@ -60,6 +62,7 @@ func (s Schema) Validate() error {
|
|||
{"Sessions", s.Tables.Sessions},
|
||||
{"Tokens", s.Tables.Tokens},
|
||||
{"APIKeys", s.Tables.APIKeys},
|
||||
{"ServiceKeys", s.Tables.ServiceKeys},
|
||||
{"Roles", s.Tables.Roles},
|
||||
{"Permissions", s.Tables.Permissions},
|
||||
{"UserRoles", s.Tables.UserRoles},
|
||||
|
|
|
|||
116
sqlstore/service_keys.go
Normal file
116
sqlstore/service_keys.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/authkit"
|
||||
"git.juancwu.dev/juancwu/errx"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type serviceKeyStore struct{ storeBase }
|
||||
|
||||
func (s *serviceKeyStore) CreateServiceKey(ctx context.Context, k *authkit.ServiceKey) error {
|
||||
const op = "authkit.sqlstore.ServiceKeyStore.CreateServiceKey"
|
||||
if k.CreatedAt.IsZero() {
|
||||
k.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if k.Abilities == nil {
|
||||
k.Abilities = []string{}
|
||||
}
|
||||
abilities, err := json.Marshal(k.Abilities)
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q.CreateServiceKey,
|
||||
k.IDHash, uuidArg(k.OwnerID), k.OwnerKind, k.Name, abilities,
|
||||
nullableTime(k.LastUsedAt), k.CreatedAt,
|
||||
nullableTime(k.ExpiresAt), nullableTime(k.RevokedAt))
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceKeyStore) GetServiceKey(ctx context.Context, idHash []byte) (*authkit.ServiceKey, error) {
|
||||
const op = "authkit.sqlstore.ServiceKeyStore.GetServiceKey"
|
||||
k, err := scanServiceKey(s.db.QueryRowContext(ctx, s.q.GetServiceKey, idHash))
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, mapNotFound(err, authkit.ErrServiceKeyInvalid))
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (s *serviceKeyStore) ListServiceKeysByOwner(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*authkit.ServiceKey, error) {
|
||||
const op = "authkit.sqlstore.ServiceKeyStore.ListServiceKeysByOwner"
|
||||
rows, err := s.db.QueryContext(ctx, s.q.ListServiceKeysByOwner, ownerKind, uuidArg(ownerID))
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*authkit.ServiceKey
|
||||
for rows.Next() {
|
||||
k, err := scanServiceKey(rows)
|
||||
if err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, errx.Wrap(op, rows.Err())
|
||||
}
|
||||
|
||||
func (s *serviceKeyStore) TouchServiceKey(ctx context.Context, idHash []byte, at time.Time) error {
|
||||
const op = "authkit.sqlstore.ServiceKeyStore.TouchServiceKey"
|
||||
if _, err := s.db.ExecContext(ctx, s.q.TouchServiceKey, at, idHash); err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceKeyStore) RevokeServiceKey(ctx context.Context, idHash []byte, at time.Time) error {
|
||||
const op = "authkit.sqlstore.ServiceKeyStore.RevokeServiceKey"
|
||||
tag, err := s.db.ExecContext(ctx, s.q.RevokeServiceKey, at, idHash)
|
||||
if err != nil {
|
||||
return errx.Wrap(op, err)
|
||||
}
|
||||
n, _ := tag.RowsAffected()
|
||||
if n == 0 {
|
||||
return errx.Wrap(op, authkit.ErrServiceKeyInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanServiceKey(row rowScanner) (*authkit.ServiceKey, error) {
|
||||
var (
|
||||
k authkit.ServiceKey
|
||||
ownerIDStr string
|
||||
abilitiesRaw []byte
|
||||
lastUsed sql.NullTime
|
||||
expires sql.NullTime
|
||||
revoked sql.NullTime
|
||||
)
|
||||
if err := row.Scan(&k.IDHash, &ownerIDStr, &k.OwnerKind, &k.Name, &abilitiesRaw,
|
||||
&lastUsed, &k.CreatedAt, &expires, &revoked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, err := scanUUID(ownerIDStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.OwnerID = owner
|
||||
if len(abilitiesRaw) > 0 {
|
||||
if err := json.Unmarshal(abilitiesRaw, &k.Abilities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if k.Abilities == nil {
|
||||
k.Abilities = []string{}
|
||||
}
|
||||
k.LastUsedAt = scanNullTimePtr(lastUsed)
|
||||
k.ExpiresAt = scanNullTimePtr(expires)
|
||||
k.RevokedAt = scanNullTimePtr(revoked)
|
||||
return &k, nil
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ type Stores struct {
|
|||
Sessions authkit.SessionStore
|
||||
Tokens authkit.TokenStore
|
||||
APIKeys authkit.APIKeyStore
|
||||
ServiceKeys authkit.ServiceKeyStore
|
||||
Roles authkit.RoleStore
|
||||
Permissions authkit.PermissionStore
|
||||
}
|
||||
|
|
@ -44,6 +45,7 @@ func New(db *sql.DB, dialect Dialect, schema Schema) (*Stores, error) {
|
|||
Sessions: &sessionStore{storeBase: base},
|
||||
Tokens: &tokenStore{storeBase: base},
|
||||
APIKeys: &apiKeyStore{storeBase: base},
|
||||
ServiceKeys: &serviceKeyStore{storeBase: base},
|
||||
Roles: &roleStore{storeBase: base},
|
||||
Permissions: &permissionStore{storeBase: base},
|
||||
}, nil
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/authkit/sqlstore"
|
||||
pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ func freshDB(t *testing.T) (*authkit.Auth, *sql.DB, sqlstore.Schema) {
|
|||
Sessions: stores.Sessions,
|
||||
Tokens: stores.Tokens,
|
||||
APIKeys: stores.APIKeys,
|
||||
ServiceKeys: stores.ServiceKeys,
|
||||
Roles: stores.Roles,
|
||||
Permissions: stores.Permissions,
|
||||
Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
|
||||
|
|
@ -88,7 +90,7 @@ func dropAuthkitTables(t *testing.T, db *sql.DB, s sqlstore.Schema) {
|
|||
tables := []string{
|
||||
s.Tables.UserRoles, s.Tables.RolePermissions,
|
||||
s.Tables.Roles, s.Tables.Permissions,
|
||||
s.Tables.APIKeys, s.Tables.Tokens,
|
||||
s.Tables.APIKeys, s.Tables.ServiceKeys, s.Tables.Tokens,
|
||||
s.Tables.Sessions, s.Tables.Users,
|
||||
s.Tables.SchemaMigrations,
|
||||
}
|
||||
|
|
@ -217,6 +219,58 @@ func TestIntegration_APIKeyWithAbilities(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ServiceKeyFlow(t *testing.T) {
|
||||
auth, _, _ := freshDB(t)
|
||||
ctx := context.Background()
|
||||
appA := uuid.New()
|
||||
appB := uuid.New()
|
||||
|
||||
plainA1, _, err := auth.IssueServiceKey(ctx, "application", appA, "events-ingest",
|
||||
[]string{"events:write"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueServiceKey appA #1: %v", err)
|
||||
}
|
||||
if _, _, err := auth.IssueServiceKey(ctx, "application", appA, "events-ingest-2", nil, nil); err != nil {
|
||||
t.Fatalf("IssueServiceKey appA #2: %v", err)
|
||||
}
|
||||
if _, _, err := auth.IssueServiceKey(ctx, "application", appB, "billing", nil, nil); err != nil {
|
||||
t.Fatalf("IssueServiceKey appB: %v", err)
|
||||
}
|
||||
|
||||
got, err := auth.AuthenticateServiceKey(ctx, plainA1)
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateServiceKey: %v", err)
|
||||
}
|
||||
if got.OwnerKind != "application" || got.OwnerID != appA {
|
||||
t.Fatalf("owner mismatch: kind=%q id=%v", got.OwnerKind, got.OwnerID)
|
||||
}
|
||||
if len(got.Abilities) != 1 || got.Abilities[0] != "events:write" {
|
||||
t.Fatalf("abilities mismatch: %+v", got.Abilities)
|
||||
}
|
||||
|
||||
listA, err := auth.ListServiceKeys(ctx, "application", appA)
|
||||
if err != nil {
|
||||
t.Fatalf("ListServiceKeys appA: %v", err)
|
||||
}
|
||||
if len(listA) != 2 {
|
||||
t.Fatalf("ListServiceKeys appA = %d, want 2", len(listA))
|
||||
}
|
||||
listB, err := auth.ListServiceKeys(ctx, "application", appB)
|
||||
if err != nil {
|
||||
t.Fatalf("ListServiceKeys appB: %v", err)
|
||||
}
|
||||
if len(listB) != 1 {
|
||||
t.Fatalf("ListServiceKeys appB = %d, want 1", len(listB))
|
||||
}
|
||||
|
||||
if err := auth.RevokeServiceKey(ctx, plainA1); err != nil {
|
||||
t.Fatalf("RevokeServiceKey: %v", err)
|
||||
}
|
||||
if _, err := auth.AuthenticateServiceKey(ctx, plainA1); !errors.Is(err, authkit.ErrServiceKeyInvalid) {
|
||||
t.Fatalf("expected ErrServiceKeyInvalid post-revoke, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RBAC(t *testing.T) {
|
||||
auth, db, schema := freshDB(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ type APIKeyStore interface {
|
|||
RevokeAPIKeysByOwner(ctx context.Context, ownerID uuid.UUID, at time.Time) error
|
||||
}
|
||||
|
||||
type ServiceKeyStore interface {
|
||||
CreateServiceKey(ctx context.Context, k *ServiceKey) error
|
||||
GetServiceKey(ctx context.Context, idHash []byte) (*ServiceKey, error)
|
||||
ListServiceKeysByOwner(ctx context.Context, ownerKind string, ownerID uuid.UUID) ([]*ServiceKey, error)
|
||||
TouchServiceKey(ctx context.Context, idHash []byte, at time.Time) error
|
||||
RevokeServiceKey(ctx context.Context, idHash []byte, at time.Time) error
|
||||
}
|
||||
|
||||
type RoleStore interface {
|
||||
CreateRole(ctx context.Context, r *Role) error
|
||||
GetRoleByID(ctx context.Context, id uuid.UUID) (*Role, error)
|
||||
|
|
|
|||
46
tokens.go
46
tokens.go
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue