Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions exchange/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import (
)

// APITokenVerifier verifies opaque API tokens and returns their authenticated metadata.
// Implementations must return authkit.ErrUnauthenticated (directly or wrapped) when plaintext is
// Implementations must return authkit.ErrUnauthenticated (directly or wrapped) when the token is
// not a currently valid token; other errors are treated as internal failures.
type APITokenVerifier interface {
// VerifyAPIToken verifies plaintext and returns its authenticated token metadata.
VerifyAPIToken(ctx context.Context, plaintext string) (apikey.VerifiedToken, error)
// VerifyAPIToken verifies token and returns its authenticated token metadata.
VerifyAPIToken(ctx context.Context, token string) (apikey.VerifiedToken, error)
}

// APITokenOptions configures an APITokenExchanger.
Expand All @@ -31,8 +31,8 @@ type APITokenOptions struct {

// APITokenRequest describes an API-token exchange request.
type APITokenRequest struct {
// Plaintext is the opaque API token presented for exchange.
Plaintext string
// Token is the opaque API token presented for exchange.
Token string
}

// APITokenResult describes a completed API-token exchange.
Expand Down Expand Up @@ -73,13 +73,13 @@ func NewAPITokenExchanger(opts APITokenOptions) (*APITokenExchanger, error) {
}, nil
}

// Exchange verifies req.Plaintext and issues an access JWT for the token principal.
// Exchange verifies req.Token and issues an access JWT for the token principal.
func (e *APITokenExchanger) Exchange(ctx context.Context, req APITokenRequest) (APITokenResult, error) {
if err := ctx.Err(); err != nil {
return APITokenResult{}, err
}

apiToken, err := e.apiTokens.VerifyAPIToken(ctx, req.Plaintext)
apiToken, err := e.apiTokens.VerifyAPIToken(ctx, req.Token)
if err != nil {
return APITokenResult{}, exchangeError("verify API token", err)
}
Expand Down
10 changes: 5 additions & 5 deletions exchange/apitoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestAPITokenExchangerExchangesTokenForAccessJWT(t *testing.T) {
})

result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{
Plaintext: apiToken.Plaintext,
Token: apiToken.Token,
})

require.NoError(t, err)
Expand Down Expand Up @@ -118,7 +118,7 @@ func TestAPITokenExchangerRejectsMissingPrincipal(t *testing.T) {
})

result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{
Plaintext: "ak_token_secret",
Token: "ak_token_secret",
})

require.ErrorIs(t, err, authkit.ErrUnauthenticated)
Expand All @@ -137,7 +137,7 @@ func TestAPITokenExchangerRejectsInvalidAPIToken(t *testing.T) {
})

result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{
Plaintext: "invalid",
Token: "invalid",
})

require.ErrorIs(t, err, authkit.ErrUnauthenticated)
Expand Down Expand Up @@ -202,7 +202,7 @@ func TestAPITokenExchangerWrapsInternalFailures(t *testing.T) {
exchanger := newAPITokenExchanger(t, tt.setupOpts(t))

result, err := exchanger.Exchange(context.Background(), exchange.APITokenRequest{
Plaintext: "ak_token_secret",
Token: "ak_token_secret",
})

require.ErrorIs(t, err, authkit.ErrInternal)
Expand All @@ -222,7 +222,7 @@ func TestAPITokenExchangerPassesThroughContextErrors(t *testing.T) {
cancel()

result, err := exchanger.Exchange(ctx, exchange.APITokenRequest{
Plaintext: "ak_token_secret",
Token: "ak_token_secret",
})

require.ErrorIs(t, err, context.Canceled)
Expand Down
2 changes: 1 addition & 1 deletion internal/storetest/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ func runTokenSuite(t *testing.T, newStore func(t *testing.T) Store) {
})
require.NoError(t, err)

verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := service.VerifyAPIToken(context.Background(), issued.Token)
require.NoError(t, err)
assert.Equal(t, apikey.VerifiedToken{
ID: issued.ID,
Expand Down
6 changes: 3 additions & 3 deletions management/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ type IssuedAPIToken struct {
// PrincipalID identifies the principal the token authenticates as.
PrincipalID string

// Plaintext is the full token secret shown once to the caller.
Plaintext string
// Token is the full opaque token shown once to the caller (format: ak_<id>_<secret>).
Token string

// ExpiresAt is the time after which the token must no longer authenticate.
ExpiresAt time.Time
Expand Down Expand Up @@ -74,7 +74,7 @@ func (s *Service) IssueAPIToken(ctx context.Context, req IssueAPITokenRequest) (
return IssuedAPIToken{
ID: issued.ID,
PrincipalID: principal.ID,
Plaintext: issued.Plaintext,
Token: issued.Token,
ExpiresAt: issued.ExpiresAt,
}, nil
}
Expand Down
4 changes: 2 additions & 2 deletions management/apitoken_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) {
apiTokens := newFakeAPITokens()
apiTokens.issued = apikey.IssuedToken{
ID: testTokenID,
Plaintext: testTokenSecret,
Token: testToken,
ExpiresAt: expiresAt,
}
principal := authkit.Principal{
Expand Down Expand Up @@ -53,7 +53,7 @@ func TestServiceIssueAPITokenIssuesForExistingPrincipal(t *testing.T) {
assert.Equal(t, management.IssuedAPIToken{
ID: testTokenID,
PrincipalID: testPrincipalID,
Plaintext: testTokenSecret,
Token: testToken,
ExpiresAt: expiresAt,
}, issued)
}
Expand Down
2 changes: 1 addition & 1 deletion management/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const (
testPrincipalID = "principal_1"
testTokenID = "token_1"
testTokenName = "deploy token"
testTokenSecret = "ak_token_1_secret"
testToken = "ak_token_1_secret"
testProvider = "oidc"
testSubject = "user-123"
testPrincipalName = "deploy service"
Expand Down
2 changes: 1 addition & 1 deletion management/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ func TestServiceIssueAPITokenResolvesThroughMemoryStore(t *testing.T) {
ExpiresAt: now.Add(time.Hour),
})
require.NoError(t, err)
verified, err := tokenService.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := tokenService.VerifyAPIToken(context.Background(), issued.Token)
require.NoError(t, err)
assert.Equal(t, principal.ID, issued.PrincipalID)
assert.Equal(t, apikey.VerifiedToken{
Expand Down
4 changes: 2 additions & 2 deletions proof/apikey/doc.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package apikey provides opaque API-token issuing and verification.
//
// Tokens are storage-backed, revocable credentials. The service stores only a
// SHA-256 hash of the token secret, returns the plaintext token only at issue
// time, and verifies successful tokens to principal-bearing token metadata for
// SHA-256 hash of the token secret, returns the opaque token only at issue
// time, and verifies presented tokens to principal-bearing token metadata for
// access JWT exchange.
package apikey
2 changes: 2 additions & 0 deletions proof/apikey/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import "time"
// Option configures a Service.
type Option func(*options)

// options is the resolved configuration consumed by a Service.
type options struct {
clock func() time.Time
}

// defaultOptions returns the baseline options applied before any caller-supplied Option.
func defaultOptions() options {
return options{
clock: time.Now,
Expand Down
24 changes: 17 additions & 7 deletions proof/apikey/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func (s *Service) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken

tokenID := rand.Text()
secret := rand.Text()
plaintext := formatToken(tokenID, secret)
token := formatToken(tokenID, secret)

if err := s.store.CreateToken(ctx, StoredToken{
ID: tokenID,
Expand All @@ -74,23 +74,25 @@ func (s *Service) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken

return IssuedToken{
ID: tokenID,
Plaintext: plaintext,
Token: token,
ExpiresAt: req.ExpiresAt,
}, nil
}

// VerifyAPIToken authenticates plaintext and returns its API-token metadata.
func (s *Service) VerifyAPIToken(ctx context.Context, plaintext string) (VerifiedToken, error) {
// VerifyAPIToken authenticates token and returns its API-token metadata.
func (s *Service) VerifyAPIToken(ctx context.Context, token string) (VerifiedToken, error) {
if err := ctx.Err(); err != nil {
return VerifiedToken{}, err
}

tokenID, secret, ok := parseToken(plaintext)
tokenID, secret, ok := parseToken(token)
if !ok {
return VerifiedToken{}, unauthenticated("malformed token")
}

stored, err := s.store.FindToken(ctx, tokenID)
// Token-not-found collapses to the generic unauthenticated response so the API never
// leaks whether a specific token ID exists in storage.
if errors.Is(err, ErrTokenNotFound) {
return VerifiedToken{}, unauthenticated("token not found")
}
Expand All @@ -99,6 +101,8 @@ func (s *Service) VerifyAPIToken(ctx context.Context, plaintext string) (Verifie
}

secretHash := hashSecret(secret)
// Constant-time comparison defends against timing side-channel attacks that could
// otherwise reveal the stored secret hash byte by byte.
if subtle.ConstantTimeCompare(secretHash[:], stored.SecretHash[:]) != 1 {
return VerifiedToken{}, unauthenticated("token secret mismatch")
}
Expand Down Expand Up @@ -140,12 +144,15 @@ func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
return nil
}

// formatToken assembles the opaque token form ak_<tokenID>_<secret>.
func formatToken(tokenID string, secret string) string {
return tokenPartPrefix + tokenID + tokenSeparator + secret
}

func parseToken(plaintext string) (string, string, bool) {
rest, ok := strings.CutPrefix(plaintext, tokenPartPrefix)
// parseToken splits an opaque ak_<tokenID>_<secret> token into its parts, rejecting any
// shape that would let an attacker hide separator characters inside the secret.
func parseToken(token string) (string, string, bool) {
rest, ok := strings.CutPrefix(token, tokenPartPrefix)
if !ok {
return "", "", false
}
Expand All @@ -158,10 +165,13 @@ func parseToken(plaintext string) (string, string, bool) {
return tokenID, secret, true
}

// hashSecret returns the SHA-256 hash of secret used as the at-rest comparison value.
func hashSecret(secret string) [sha256.Size]byte {
return sha256.Sum256([]byte(secret))
}

// unauthenticated wraps reason as an authkit.ErrUnauthenticated so callers can detect
// auth failures with [errors.Is] without inspecting message strings.
func unauthenticated(reason string) error {
return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason)
}
30 changes: 15 additions & 15 deletions proof/apikey/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ func TestServiceIssueToken(t *testing.T) {
require.NoError(t, err)
assert.NotEmpty(t, issued.ID)
assert.Equal(t, expiresAt, issued.ExpiresAt)
require.True(t, strings.HasPrefix(issued.Plaintext, "ak_"+issued.ID+"_"))
require.Len(t, strings.Split(issued.Plaintext, "_"), tokenParts)
require.True(t, strings.HasPrefix(issued.Token, "ak_"+issued.ID+"_"))
require.Len(t, strings.Split(issued.Token, "_"), tokenParts)

stored, err := store.FindToken(context.Background(), issued.ID)
require.NoError(t, err)
Expand Down Expand Up @@ -87,7 +87,7 @@ func TestServiceVerifyAPIToken(t *testing.T) {
service, store := newService(t, now)
issued := issueToken(t, service, now.Add(time.Hour))

verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := service.VerifyAPIToken(context.Background(), issued.Token)

require.NoError(t, err)
assert.Equal(t, apikey.VerifiedToken{
Expand All @@ -110,18 +110,18 @@ func TestServiceVerifyAPITokenRejectsInvalidTokens(t *testing.T) {
require.NoError(t, service.RevokeToken(context.Background(), revoked.ID))

tests := []struct {
name string
plaintext string
name string
token string
}{
{name: "malformed", plaintext: "not-a-token"},
{name: "unknown ID", plaintext: "ak_missing_secret"},
{name: "wrong secret", plaintext: replaceTokenSecret(t, issued.Plaintext, "wrong")},
{name: "revoked", plaintext: revoked.Plaintext},
{name: "malformed", token: "not-a-token"},
{name: "unknown ID", token: "ak_missing_secret"},
{name: "wrong secret", token: replaceTokenSecret(t, issued.Token, "wrong")},
{name: "revoked", token: revoked.Token},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
verified, err := service.VerifyAPIToken(context.Background(), tt.plaintext)
verified, err := service.VerifyAPIToken(context.Background(), tt.token)

require.ErrorIs(t, err, authkit.ErrUnauthenticated)
assert.Empty(t, verified)
Expand All @@ -138,7 +138,7 @@ func TestServiceVerifyAPITokenRejectsExpiredToken(t *testing.T) {
issued := issueToken(t, service, now.Add(time.Hour))
current = now.Add(time.Hour)

verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := service.VerifyAPIToken(context.Background(), issued.Token)

require.ErrorIs(t, err, authkit.ErrUnauthenticated)
assert.Empty(t, verified)
Expand All @@ -156,7 +156,7 @@ func TestServiceVerifyAPITokenIgnoresLastUsedUpdateErrors(t *testing.T) {
}))
require.NoError(t, err)

verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := service.VerifyAPIToken(context.Background(), issued.Token)

require.NoError(t, err)
assert.Equal(t, issued.ID, verified.ID)
Expand All @@ -176,7 +176,7 @@ func TestServiceRevokeToken(t *testing.T) {
require.NotNil(t, stored.RevokedAt)
assert.Equal(t, now, *stored.RevokedAt)

verified, err := service.VerifyAPIToken(context.Background(), issued.Plaintext)
verified, err := service.VerifyAPIToken(context.Background(), issued.Token)
require.ErrorIs(t, err, authkit.ErrUnauthenticated)
assert.Empty(t, verified)
}
Expand Down Expand Up @@ -233,10 +233,10 @@ func issueTokenForPrincipal(
return issued
}

func replaceTokenSecret(t *testing.T, plaintext string, secret string) string {
func replaceTokenSecret(t *testing.T, token string, secret string) string {
t.Helper()

parts := strings.Split(plaintext, "_")
parts := strings.Split(token, "_")
require.Len(t, parts, tokenParts)

return strings.Join([]string{parts[0], parts[1], secret}, "_")
Expand Down
4 changes: 2 additions & 2 deletions proof/apikey/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ type IssuedToken struct {
// ID is the stable lookup identifier embedded in the token.
ID string

// Plaintext is the full token secret shown once to the caller.
Plaintext string
// Token is the full opaque token shown once to the caller (format: ak_<id>_<secret>).
Token string

// ExpiresAt is the time after which the token must no longer authenticate.
ExpiresAt time.Time
Expand Down
10 changes: 5 additions & 5 deletions testkit/internal/authflow/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ type Runtime struct {
// Principal is the bootstrap principal that owns the startup API token.
Principal authkit.Principal

// SeedAPIToken is the plaintext startup API token shown once on stdout.
// SeedAPIToken is the startup API token shown once on stdout.
SeedAPIToken string

// SeedAPITokenExpiresAt is when SeedAPIToken stops being accepted for exchange.
Expand Down Expand Up @@ -250,7 +250,7 @@ func NewRuntime(ctx context.Context, store Store, opts ...Option) (*Runtime, err
OIDCVerifier: oidcVerifier,
Passkeys: passkeys,
Principal: principal,
SeedAPIToken: seedToken.Plaintext,
SeedAPIToken: seedToken.Token,
SeedAPITokenExpiresAt: seedToken.ExpiresAt,
}, nil
}
Expand Down Expand Up @@ -278,17 +278,17 @@ func runtimeOptions(opts []Option) (options, error) {
return cfg, nil
}

// ExchangeAPIToken exchanges plaintext for an authkit access JWT.
// ExchangeAPIToken exchanges the opaque API token for an authkit access JWT.
func (r *Runtime) ExchangeAPIToken(
ctx context.Context,
plaintext string,
token string,
) (exchange.APITokenResult, error) {
if r == nil || r.Exchanger == nil {
return exchange.APITokenResult{}, errors.New("authflow: runtime exchanger is required")
}

return r.Exchanger.Exchange(ctx, exchange.APITokenRequest{
Plaintext: plaintext,
Token: token,
})
}

Expand Down
2 changes: 1 addition & 1 deletion testkit/internal/httpui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type Server struct {
}

type authRuntime interface {
ExchangeAPIToken(ctx context.Context, plaintext string) (exchange.APITokenResult, error)
ExchangeAPIToken(ctx context.Context, token string) (exchange.APITokenResult, error)
ExchangeOIDCToken(ctx context.Context, plaintext string) (exchange.IdentityResult, error)
BeginPasskeyRegistration(ctx context.Context, principal authkit.Principal) (passkey.BeginRegistrationResult, error)
FinishPasskeyRegistration(
Expand Down