From 37e465b5b45579386b5c76bc2f5e7fb9e4594b60 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:29:10 -0700 Subject: [PATCH 1/7] refactor(access/jwt): rename IssuedToken.Plaintext to IssuedToken.JWT "Plaintext" implied "unencrypted body" in a crypto context. The field carries the signed compact JWS serialization, so JWT names it accurately. Mechanical rename across jwt.IssuedToken consumers; apikey.IssuedToken keeps its own Plaintext naming until proof/apikey's own refactor pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/jwt/issuer.go | 2 +- access/jwt/jwt_test.go | 14 +++++++------- access/jwt/types.go | 4 ++-- access/middleware/authenticator_test.go | 12 ++++++------ authz/role/authorizer_test.go | 2 +- exchange/apitoken_test.go | 2 +- exchange/identity_test.go | 2 +- http/compose/http_test.go | 2 +- testkit/internal/authflow/runtime.go | 2 +- .../internal/authflow/runtime_integration_test.go | 4 ++-- testkit/internal/authflow/runtime_test.go | 4 ++-- testkit/internal/httpui/server_test.go | 4 ++-- 12 files changed, 27 insertions(+), 27 deletions(-) diff --git a/access/jwt/issuer.go b/access/jwt/issuer.go index 79a8eb9..96db33c 100644 --- a/access/jwt/issuer.go +++ b/access/jwt/issuer.go @@ -117,7 +117,7 @@ func (i *Issuer) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken, return IssuedToken{ ID: tokenID, - Plaintext: string(signed), + JWT: string(signed), PrincipalID: req.PrincipalID, IssuedAt: issuedAt, ExpiresAt: expiresAt, diff --git a/access/jwt/jwt_test.go b/access/jwt/jwt_test.go index ff23a27..55f93f3 100644 --- a/access/jwt/jwt_test.go +++ b/access/jwt/jwt_test.go @@ -173,11 +173,11 @@ func TestIssueAndVerifyToken(t *testing.T) { assert.Equal(t, testPrincipalID, issued.PrincipalID) assert.Equal(t, fixedTime(), issued.IssuedAt) assert.Equal(t, fixedTime().Add(time.Hour), issued.ExpiresAt) - assert.NotEmpty(t, issued.Plaintext) - assertProtectedHeader(t, issued.Plaintext, TokenType, DefaultAlgorithm, testKeyID) - assertNoAuthorizationClaims(t, issued.Plaintext, keySet) + assert.NotEmpty(t, issued.JWT) + assertProtectedHeader(t, issued.JWT, TokenType, DefaultAlgorithm, testKeyID) + assertNoAuthorizationClaims(t, issued.JWT, keySet) - verified, err := verifier.VerifyToken(context.Background(), issued.Plaintext) + verified, err := verifier.VerifyToken(context.Background(), issued.JWT) require.NoError(t, err) assert.Equal(t, testTokenID, verified.ID) @@ -293,7 +293,7 @@ func TestVerifiedTokenUsesStorageBackedAuthorization(t *testing.T) { PrincipalID: principal.ID, }) require.NoError(t, err) - verified, err := verifier.VerifyToken(context.Background(), issued.Plaintext) + verified, err := verifier.VerifyToken(context.Background(), issued.JWT) require.NoError(t, err) loaded, err := store.FindPrincipal(context.Background(), verified.PrincipalID) require.NoError(t, err) @@ -373,7 +373,7 @@ func issueTokenWithOptions(t *testing.T, mutate func(*IssuerOptions)) string { issuer, err := NewIssuer(issuerOptions(privateKey, mutate)) require.NoError(t, err) - return issueToken(t, issuer).Plaintext + return issueToken(t, issuer).JWT } func issueTokenWithClock(t *testing.T, now time.Time) string { @@ -393,7 +393,7 @@ func issueWithWrongSignature(t *testing.T) string { issuer, err := NewIssuer(issuerOptions(privateKey, nil)) require.NoError(t, err) - return issueToken(t, issuer).Plaintext + return issueToken(t, issuer).JWT } func issueWithWrongKeyID(t *testing.T) string { diff --git a/access/jwt/types.go b/access/jwt/types.go index a5a89cf..be36e23 100644 --- a/access/jwt/types.go +++ b/access/jwt/types.go @@ -28,8 +28,8 @@ type IssuedToken struct { // ID is the token's jti claim. ID string - // Plaintext is the signed compact JWT returned to the caller. - Plaintext string + // JWT is the signed compact JWS serialization returned to the caller. + JWT string // PrincipalID is the principal authenticated by the token. PrincipalID string diff --git a/access/middleware/authenticator_test.go b/access/middleware/authenticator_test.go index d018220..73bd566 100644 --- a/access/middleware/authenticator_test.go +++ b/access/middleware/authenticator_test.go @@ -71,7 +71,7 @@ func TestAuthenticatorAuthenticatesAccessJWT(t *testing.T) { PrincipalID: principal.ID, }) require.NoError(t, err) - req := requestWithBearer(issued.Plaintext) + req := requestWithBearer(issued.JWT) authentication, err := authenticator.AuthenticatePrincipal(context.Background(), req) @@ -126,12 +126,12 @@ func TestAuthenticatorRejectsInvalidRequests(t *testing.T) { req *http.Request }{ {name: "missing bearer", req: httptest.NewRequest(http.MethodGet, "/", nil)}, - {name: "malformed bearer", req: requestWithAuthorization("Basic " + valid.Plaintext)}, + {name: "malformed bearer", req: requestWithAuthorization("Basic " + valid.JWT)}, {name: "invalid JWT", req: requestWithBearer("not-a-jwt")}, - {name: "expired JWT", req: requestWithBearer(expired.Plaintext)}, - {name: "wrong issuer", req: requestWithBearer(wrongIssuerToken.Plaintext)}, - {name: "wrong audience", req: requestWithBearer(wrongAudienceToken.Plaintext)}, - {name: "missing principal", req: requestWithBearer(missingPrincipal.Plaintext)}, + {name: "expired JWT", req: requestWithBearer(expired.JWT)}, + {name: "wrong issuer", req: requestWithBearer(wrongIssuerToken.JWT)}, + {name: "wrong audience", req: requestWithBearer(wrongAudienceToken.JWT)}, + {name: "missing principal", req: requestWithBearer(missingPrincipal.JWT)}, } for _, tt := range tests { diff --git a/authz/role/authorizer_test.go b/authz/role/authorizer_test.go index d98a716..5aaae6e 100644 --- a/authz/role/authorizer_test.go +++ b/authz/role/authorizer_test.go @@ -169,7 +169,7 @@ func TestAuthorizerAllowsHTTPMiddlewareThroughAccessJWT(t *testing.T) { mux := http.NewServeMux() mux.Handle("GET /notes/{noteID}", handler) req := httptest.NewRequest(http.MethodGet, "/notes/42", nil) - req.Header.Set("Authorization", "Bearer "+issued.Plaintext) + req.Header.Set("Authorization", "Bearer "+issued.JWT) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, req) diff --git a/exchange/apitoken_test.go b/exchange/apitoken_test.go index c1be904..1991ffb 100644 --- a/exchange/apitoken_test.go +++ b/exchange/apitoken_test.go @@ -99,7 +99,7 @@ func TestAPITokenExchangerExchangesTokenForAccessJWT(t *testing.T) { }, result.APIToken) assert.Equal(t, principal, result.Principal) assert.Equal(t, principal.ID, result.AccessToken.PrincipalID) - verified, err := verifier.VerifyToken(ctx, result.AccessToken.Plaintext) + verified, err := verifier.VerifyToken(ctx, result.AccessToken.JWT) require.NoError(t, err) assert.Equal(t, principal.ID, verified.PrincipalID) } diff --git a/exchange/identity_test.go b/exchange/identity_test.go index 579af4a..a01a58d 100644 --- a/exchange/identity_test.go +++ b/exchange/identity_test.go @@ -52,7 +52,7 @@ func TestIdentityExchangerExchangesResolvedIdentityForAccessJWT(t *testing.T) { accessToken := jwt.IssuedToken{ ID: testTokenID, PrincipalID: principal.ID, - Plaintext: "access.jwt", + JWT: "access.jwt", ExpiresAt: fixedTime().Add(time.Hour), } exchanger := newIdentityExchanger(t, exchange.IdentityOptions{ diff --git a/http/compose/http_test.go b/http/compose/http_test.go index 338c24a..7cc1d52 100644 --- a/http/compose/http_test.go +++ b/http/compose/http_test.go @@ -115,7 +115,7 @@ func TestNewHTTPWithAccessJWTAuthenticatesPrincipals(t *testing.T) { }) require.NoError(t, err) - authentication, err := kit.Pipeline.Authenticate(ctx, requestWithBearer(issued.Plaintext)) + authentication, err := kit.Pipeline.Authenticate(ctx, requestWithBearer(issued.JWT)) require.NoError(t, err) assert.Equal(t, principal.ID, authentication.Principal.ID) diff --git a/testkit/internal/authflow/runtime.go b/testkit/internal/authflow/runtime.go index b60207f..8814990 100644 --- a/testkit/internal/authflow/runtime.go +++ b/testkit/internal/authflow/runtime.go @@ -401,7 +401,7 @@ func (r *Runtime) AuthorizeAuthenticated( func SetAccessCookie(w http.ResponseWriter, token jwt.IssuedToken) { http.SetCookie(w, &http.Cookie{ Name: CookieName, - Value: token.Plaintext, + Value: token.JWT, Path: accessCookiePath, Expires: token.ExpiresAt, MaxAge: accessCookieMaxAge, diff --git a/testkit/internal/authflow/runtime_integration_test.go b/testkit/internal/authflow/runtime_integration_test.go index 17eabae..bee2bfa 100644 --- a/testkit/internal/authflow/runtime_integration_test.go +++ b/testkit/internal/authflow/runtime_integration_test.go @@ -39,7 +39,7 @@ func TestRuntimeUsesPostgresAuthStore(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", bearer(result.AccessToken.Plaintext)) + req.Header.Set("Authorization", bearer(result.AccessToken.JWT)) runtime.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { principal, ok := auth.PrincipalFromContext(req.Context()) assert.True(t, ok) @@ -86,7 +86,7 @@ func TestRuntimeUsesPostgresAuthStoreForOIDCExchange(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", bearer(result.AccessToken.Plaintext)) + req.Header.Set("Authorization", bearer(result.AccessToken.JWT)) runtime.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { principal, ok := auth.PrincipalFromContext(req.Context()) assert.True(t, ok) diff --git a/testkit/internal/authflow/runtime_test.go b/testkit/internal/authflow/runtime_test.go index f3e57a8..bcc58de 100644 --- a/testkit/internal/authflow/runtime_test.go +++ b/testkit/internal/authflow/runtime_test.go @@ -37,7 +37,7 @@ func TestRuntimeExchangesSeedAPITokenForAccessJWT(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", bearer(result.AccessToken.Plaintext)) + req.Header.Set("Authorization", bearer(result.AccessToken.JWT)) runtime.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { principal, ok := auth.PrincipalFromContext(req.Context()) assert.True(t, ok) @@ -198,7 +198,7 @@ func TestRuntimeExchangesOIDCTokenForAccessJWT(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/protected", nil) - req.Header.Set("Authorization", bearer(result.AccessToken.Plaintext)) + req.Header.Set("Authorization", bearer(result.AccessToken.JWT)) runtime.Authenticate(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { principal, ok := auth.PrincipalFromContext(req.Context()) assert.True(t, ok) diff --git a/testkit/internal/httpui/server_test.go b/testkit/internal/httpui/server_test.go index 7bd1cb2..f29882a 100644 --- a/testkit/internal/httpui/server_test.go +++ b/testkit/internal/httpui/server_test.go @@ -246,7 +246,7 @@ func TestServerPasskeyLoginFinishSetsAccessCookie(t *testing.T) { finishPasskeyLoginResult: exchange.IdentityResult{ Principal: authkit.Principal{ID: "principal-1"}, AccessToken: jwt.IssuedToken{ - Plaintext: "access-token", + JWT: "access-token", PrincipalID: "principal-1", ExpiresAt: fixedTime().Add(time.Hour), }, @@ -359,7 +359,7 @@ func TestServerRejectsPasskeyLoginFinishCeremonyFailures(t *testing.T) { auth := &fakeAuthRuntime{ finishPasskeyLoginResult: exchange.IdentityResult{ AccessToken: jwt.IssuedToken{ - Plaintext: "access-token", + JWT: "access-token", PrincipalID: "principal-1", ExpiresAt: fixedTime().Add(time.Hour), }, From d5fd3b5da9417d007d075515a2d3a7a542f15d1a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:31:12 -0700 Subject: [PATCH 2/7] refactor(access/jwt): document private helpers and annotate JWS security gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New validation.go absorbs validateRequiredString from issuer.go so Issuer and Verifier share the helper from a neutral location. - Add godocs to every private helper in keys.go, issuer.go, and verifier.go. - Annotate validateProtectedHeaders as the JWS security gate with one comment per check naming the attack class it defends against (multi-signature, token-type confusion, ambiguous kid, alg substitution, unrecognised crit per RFC 7515 §4.1.11). - Rename VerifyToken's parameter plaintext to jwt; the value is a compact JWS, not an unencrypted body. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/jwt/issuer.go | 14 ++------------ access/jwt/keys.go | 9 +++++++++ access/jwt/validation.go | 18 ++++++++++++++++++ access/jwt/verifier.go | 26 +++++++++++++++++++++----- 4 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 access/jwt/validation.go diff --git a/access/jwt/issuer.go b/access/jwt/issuer.go index 96db33c..cd5d2b3 100644 --- a/access/jwt/issuer.go +++ b/access/jwt/issuer.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "errors" "fmt" - "strings" "time" "github.com/lestrrat-go/jwx/v3/jwa" @@ -124,17 +123,7 @@ func (i *Issuer) IssueToken(ctx context.Context, req IssueRequest) (IssuedToken, }, nil } -func validateRequiredString(name string, value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("jwt: %s is required", name) - } - if strings.TrimSpace(value) != value { - return fmt.Errorf("jwt: %s must not contain surrounding whitespace", name) - } - - return nil -} - +// defaultString returns value when non-empty and fallback otherwise. func defaultString(value string, fallback string) string { if value == "" { return fallback @@ -143,6 +132,7 @@ func defaultString(value string, fallback string) string { return value } +// randomTokenID returns a cryptographically random token ID for use as the jti claim. func randomTokenID() (string, error) { return rand.Text(), nil } diff --git a/access/jwt/keys.go b/access/jwt/keys.go index 91170ea..3d03b11 100644 --- a/access/jwt/keys.go +++ b/access/jwt/keys.go @@ -9,6 +9,8 @@ import ( "github.com/lestrrat-go/jwx/v3/jwk" ) +// signatureAlgorithms resolves names into a deduplicated allowlist of JWA signature algorithms, +// defaulting to DefaultAlgorithm when names is empty. func signatureAlgorithms(names []string) (map[string]jwa.SignatureAlgorithm, error) { if len(names) == 0 { names = []string{DefaultAlgorithm} @@ -30,6 +32,8 @@ func signatureAlgorithms(names []string) (map[string]jwa.SignatureAlgorithm, err return algorithmMap, nil } +// signatureAlgorithm parses name into a JWA signature algorithm and rejects empty, +// symmetric, and unsupported algorithms. func signatureAlgorithm(name string) (jwa.SignatureAlgorithm, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { @@ -50,6 +54,8 @@ func signatureAlgorithm(name string) (jwa.SignatureAlgorithm, error) { return algorithm, nil } +// validateKeySet rejects key sets whose entries are missing, lack a kid, declare a non-signature +// algorithm, or declare an algorithm outside the allowlist. func validateKeySet(set jwk.Set, allowed map[string]jwa.SignatureAlgorithm) error { for index := range set.Len() { key, ok := set.Key(index) @@ -79,6 +85,7 @@ func validateKeySet(set jwk.Set, allowed map[string]jwa.SignatureAlgorithm) erro return nil } +// validateKeyID requires key to carry a non-empty kid with no surrounding whitespace. func validateKeyID(name string, key jwk.Key) error { keyID, ok := key.KeyID() if !ok || strings.TrimSpace(keyID) == "" { @@ -91,6 +98,8 @@ func validateKeyID(name string, key jwk.Key) error { return nil } +// validateOptionalKeyAlgorithm requires key's declared algorithm, when present, to be a signature +// algorithm matching expected. Keys without a declared algorithm are accepted. func validateOptionalKeyAlgorithm(name string, key jwk.Key, expected jwa.SignatureAlgorithm) error { keyAlgorithm, ok := key.Algorithm() if !ok { diff --git a/access/jwt/validation.go b/access/jwt/validation.go new file mode 100644 index 0000000..d6c26de --- /dev/null +++ b/access/jwt/validation.go @@ -0,0 +1,18 @@ +package jwt + +import ( + "fmt" + "strings" +) + +// validateRequiredString rejects empty values and values padded with surrounding whitespace. +func validateRequiredString(name string, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("jwt: %s is required", name) + } + if strings.TrimSpace(value) != value { + return fmt.Errorf("jwt: %s must not contain surrounding whitespace", name) + } + + return nil +} diff --git a/access/jwt/verifier.go b/access/jwt/verifier.go index fa1726d..7770120 100644 --- a/access/jwt/verifier.go +++ b/access/jwt/verifier.go @@ -67,21 +67,21 @@ func NewVerifier(opts VerifierOptions) (*Verifier, error) { }, nil } -// VerifyToken verifies plaintext and returns its principal token metadata. -func (v *Verifier) VerifyToken(ctx context.Context, plaintext string) (VerifiedToken, error) { +// VerifyToken verifies a signed compact JWS and returns its principal token metadata. +func (v *Verifier) VerifyToken(ctx context.Context, jwt string) (VerifiedToken, error) { if err := ctx.Err(); err != nil { return VerifiedToken{}, err } - if plaintext == "" { + if jwt == "" { return VerifiedToken{}, unauthenticated("token is required") } - if err := v.validateProtectedHeaders([]byte(plaintext)); err != nil { + if err := v.validateProtectedHeaders([]byte(jwt)); err != nil { return VerifiedToken{}, unauthenticated(err.Error()) } token, err := jwxjwt.Parse( - []byte(plaintext), + []byte(jwt), jwxjwt.WithKeySet(v.keySet), jwxjwt.WithIssuer(v.issuer), jwxjwt.WithAudience(v.audience), @@ -104,12 +104,17 @@ func (v *Verifier) VerifyToken(ctx context.Context, plaintext string) (VerifiedT return verified, nil } +// validateProtectedHeaders is the JWS security gate executed before signature verification. +// Each check defends against a specific JWT attack class; do not relax without an explicit threat +// model review. func (v *Verifier) validateProtectedHeaders(raw []byte) error { message, err := jws.Parse(raw, jws.WithCompact()) if err != nil { return errors.New("malformed JWT") } + // Reject multi-signature JWS: defends against signature-stripping and downgrade variants + // where extra recipients carry weaker signatures than the one we'd verify. signatures := message.Signatures() if len(signatures) != 1 { return errors.New("JWT must have exactly one signature") @@ -119,14 +124,19 @@ func (v *Verifier) validateProtectedHeaders(raw []byte) error { if headers == nil { return errors.New("JWT protected header is required") } + // typ must be at+jwt: defends against token-type confusion (e.g. an OIDC id_token replayed + // as an access token, or an unrelated signed JWT minted for another purpose). tokenType, ok := headers.Type() if !ok || tokenType != TokenType { return errors.New("JWT type must be at+jwt") } + // kid must be present: enforces unambiguous key selection from the JWKS at verification. keyID, ok := headers.KeyID() if !ok || keyID == "" { return errors.New("JWT key ID is required") } + // alg must be in the allowlist: defends against algorithm-substitution attacks (e.g. RS256 → + // HS256 confusion, or "none" downgrade). algorithm, ok := headers.Algorithm() if !ok { return errors.New("JWT algorithm is required") @@ -134,6 +144,8 @@ func (v *Verifier) validateProtectedHeaders(raw []byte) error { if _, ok := v.allowedAlgorithms[algorithm.String()]; !ok { return errors.New("JWT algorithm is not allowed") } + // crit must be empty: RFC 7515 §4.1.11 requires implementations to reject JWS with + // unrecognised crit extensions, since we do not understand any such extensions. if critical, ok := headers.Critical(); ok && len(critical) > 0 { return errors.New("JWT critical headers are not supported") } @@ -141,6 +153,8 @@ func (v *Verifier) validateProtectedHeaders(raw []byte) error { return nil } +// verifiedToken projects a parsed and signature-verified jwx token into the package's +// VerifiedToken contract, rejecting tokens that omit any required claim. func (v *Verifier) verifiedToken(token jwxjwt.Token) (VerifiedToken, error) { principalID, ok := token.Subject() if !ok || principalID == "" { @@ -173,6 +187,8 @@ func (v *Verifier) verifiedToken(token jwxjwt.Token) (VerifiedToken, error) { }, nil } +// unauthenticated wraps reason with authkit.ErrUnauthenticated so callers can recognise the failure +// via errors.Is. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) } From be1d219d59b320161ce7ae18775732572c438cbb Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:33:06 -0700 Subject: [PATCH 3/7] refactor(access/jwt): split jwt_test.go by type with shared helpers Mirrors the per-domain pattern set by PRs #56-#58 on store/. jwt_test.go grew to 600+ lines mixing issuer behaviour, verifier behaviour, and a large helper surface. - issuer_test.go: option validation and issuance behaviour. - verifier_test.go: option validation, rejection cases, storage-backed authorization. - helpers_test.go: shared constants, fixtures, token-construction helpers, RSA key factories. Helper parameter "plaintext" follows the production rename to "jwt". No tests added, removed, or reorganised; the split is mechanical. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/jwt/helpers_test.go | 320 +++++++++++++++++++ access/jwt/issuer_test.go | 123 ++++++++ access/jwt/jwt_test.go | 602 ------------------------------------ access/jwt/verifier_test.go | 182 +++++++++++ 4 files changed, 625 insertions(+), 602 deletions(-) create mode 100644 access/jwt/helpers_test.go create mode 100644 access/jwt/issuer_test.go delete mode 100644 access/jwt/jwt_test.go create mode 100644 access/jwt/verifier_test.go diff --git a/access/jwt/helpers_test.go b/access/jwt/helpers_test.go new file mode 100644 index 0000000..facd54e --- /dev/null +++ b/access/jwt/helpers_test.go @@ -0,0 +1,320 @@ +package jwt + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jws" + jwxjwt "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testIssuer = "https://auth.example.test" + testAudience = "notes-api" + testPrincipalID = "principal_1" + testTokenID = "token-123" + testAction = "note:read" + testRoleID = "reader" + testKeyID = "key-1" + rsaKeyBits = 2048 +) + +func newIssuerAndVerifier(t *testing.T) (*Issuer, *Verifier, jwk.Set) { + t.Helper() + + privateKey, publicKey := newRSAKeyPair(t) + keySet := newKeySet(t, publicKey) + issuer, err := NewIssuer(issuerOptions(privateKey, nil)) + require.NoError(t, err) + verifier, err := NewVerifier(verifierOptions(keySet, nil)) + require.NoError(t, err) + + return issuer, verifier, keySet +} + +func issuerOptions(signingKey jwk.Key, mutate func(*IssuerOptions)) IssuerOptions { + opts := IssuerOptions{ + Issuer: testIssuer, + Audience: testAudience, + TTL: time.Hour, + SigningKey: signingKey, + Clock: fixedTime, + TokenID: func() (string, error) { + return testTokenID, nil + }, + } + if mutate != nil { + mutate(&opts) + } + + return opts +} + +func verifierOptions(keySet jwk.Set, mutate func(*VerifierOptions)) VerifierOptions { + opts := VerifierOptions{ + Issuer: testIssuer, + Audience: testAudience, + KeySet: keySet, + Clock: fixedTime, + } + if mutate != nil { + mutate(&opts) + } + + return opts +} + +func issueToken(t *testing.T, issuer *Issuer) IssuedToken { + t.Helper() + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + require.NoError(t, err) + + return issued +} + +func issueTokenWithOptions(t *testing.T, mutate func(*IssuerOptions)) string { + t.Helper() + + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, mutate)) + require.NoError(t, err) + + return issueToken(t, issuer).JWT +} + +func issueTokenWithClock(t *testing.T, now time.Time) string { + t.Helper() + + return issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Clock = func() time.Time { + return now + } + }) +} + +func issueWithWrongSignature(t *testing.T) string { + t.Helper() + + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, nil)) + require.NoError(t, err) + + return issueToken(t, issuer).JWT +} + +func issueWithWrongKeyID(t *testing.T) string { + t.Helper() + + return issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.SigningKey = newRSAKey(t, "other-key", DefaultAlgorithm) + }) +} + +func signToken( + t *testing.T, + claims map[string]any, + tokenType string, + algorithmName string, + keyID string, +) string { + t.Helper() + + key := newRSAKey(t, keyID, algorithmName) + algorithm, err := signatureAlgorithm(algorithmName) + require.NoError(t, err) + token := jwxjwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, tokenType)) + signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func signTokenWithHeaders( + t *testing.T, + key jwk.Key, + claims map[string]any, + mutate func(jws.Headers), +) string { + t.Helper() + + algorithm, err := signatureAlgorithm(DefaultAlgorithm) + require.NoError(t, err) + token := jwxjwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, TokenType)) + if mutate != nil { + mutate(headers) + } + signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func signTokenWithoutType(t *testing.T, claims map[string]any) string { + t.Helper() + + token := jwxjwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + payload, err := json.Marshal(token) + require.NoError(t, err) + + key := newRSAKey(t, testKeyID, DefaultAlgorithm) + signed, err := jws.Sign(payload, jws.WithKey(jwa.RS256(), key)) + require.NoError(t, err) + + return string(signed) +} + +func unsignedToken(t *testing.T, claims map[string]any) string { + t.Helper() + + header := map[string]any{ + jws.AlgorithmKey: jwa.NoSignature().String(), + jws.TypeKey: TokenType, + } + headerBytes, err := json.Marshal(header) + require.NoError(t, err) + payloadBytes, err := json.Marshal(claims) + require.NoError(t, err) + + return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + + base64.RawURLEncoding.EncodeToString(payloadBytes) + "." +} + +func hmacSignedToken(t *testing.T, claims map[string]any) string { + t.Helper() + + key, err := jwk.Import([]byte("secret")) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, testKeyID)) + token := jwxjwt.New() + for name, value := range claims { + require.NoError(t, token.Set(name, value)) + } + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, TokenType)) + signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(jwa.HS256(), key, jws.WithProtectedHeaders(headers))) + require.NoError(t, err) + + return string(signed) +} + +func baseClaims() map[string]any { + now := fixedTime() + + return map[string]any{ + jwxjwt.IssuerKey: testIssuer, + jwxjwt.SubjectKey: testPrincipalID, + jwxjwt.AudienceKey: []string{testAudience}, + jwxjwt.IssuedAtKey: now, + jwxjwt.ExpirationKey: now.Add(time.Hour), + jwxjwt.JwtIDKey: testTokenID, + } +} + +func assertProtectedHeader(t *testing.T, jwt string, tokenType string, algorithm string, keyID string) { + t.Helper() + + message, err := jws.Parse([]byte(jwt), jws.WithCompact()) + require.NoError(t, err) + require.Len(t, message.Signatures(), 1) + headers := message.Signatures()[0].ProtectedHeaders() + require.NotNil(t, headers) + + gotType, ok := headers.Type() + require.True(t, ok) + assert.Equal(t, tokenType, gotType) + gotAlgorithm, ok := headers.Algorithm() + require.True(t, ok) + assert.Equal(t, algorithm, gotAlgorithm.String()) + gotKeyID, ok := headers.KeyID() + require.True(t, ok) + assert.Equal(t, keyID, gotKeyID) +} + +func assertNoAuthorizationClaims(t *testing.T, jwt string, keySet jwk.Set) { + t.Helper() + + token, err := jwxjwt.Parse( + []byte(jwt), + jwxjwt.WithKeySet(keySet), + jwxjwt.WithIssuer(testIssuer), + jwxjwt.WithAudience(testAudience), + jwxjwt.WithClock(jwxjwt.ClockFunc(fixedTime)), + ) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + jwxjwt.AudienceKey, + jwxjwt.ExpirationKey, + jwxjwt.IssuedAtKey, + jwxjwt.IssuerKey, + jwxjwt.JwtIDKey, + jwxjwt.SubjectKey, + }, token.Keys()) + assert.False(t, token.Has("roles")) + assert.False(t, token.Has("permissions")) + assert.False(t, token.Has("actions")) +} + +func newRSAKeyPair(t *testing.T) (jwk.Key, jwk.Key) { + t.Helper() + + privateKey := newRSAKey(t, testKeyID, DefaultAlgorithm) + publicKey, err := jwk.PublicKeyOf(privateKey) + require.NoError(t, err) + + return privateKey, publicKey +} + +func newRSAKey(t *testing.T, keyID string, algorithm string) jwk.Key { + t.Helper() + + rawKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + require.NoError(t, err) + key, err := jwk.Import(rawKey) + require.NoError(t, err) + if keyID != "" { + require.NoError(t, key.Set(jwk.KeyIDKey, keyID)) + } + if algorithm != "" { + require.NoError(t, key.Set(jwk.AlgorithmKey, algorithm)) + } + + return key +} + +func newKeySet(t *testing.T, key jwk.Key) jwk.Set { + t.Helper() + + keySet := jwk.NewSet() + require.NoError(t, keySet.AddKey(key)) + + return keySet +} + +func fixedTime() time.Time { + return time.Date(2026, time.May, 13, 21, 0, 0, 0, time.UTC) +} diff --git a/access/jwt/issuer_test.go b/access/jwt/issuer_test.go new file mode 100644 index 0000000..ba38fdd --- /dev/null +++ b/access/jwt/issuer_test.go @@ -0,0 +1,123 @@ +package jwt + +import ( + "context" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssuerRejectsInvalidOptions(t *testing.T) { + privateKey, _ := newRSAKeyPair(t) + + tests := []struct { + name string + opts IssuerOptions + }{ + { + name: "missing issuer", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Issuer = "" + }), + }, + { + name: "missing audience", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Audience = "" + }), + }, + { + name: "missing signing key", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.SigningKey = nil + }), + }, + { + name: "non-positive TTL", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.TTL = 0 + }), + }, + { + name: "signing key without kid", + opts: issuerOptions(newRSAKey(t, "", DefaultAlgorithm), nil), + }, + { + name: "none algorithm", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Algorithm = jwa.NoSignature().String() + }), + }, + { + name: "symmetric algorithm", + opts: issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.Algorithm = jwa.HS256().String() + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuer, err := NewIssuer(tt.opts) + + require.Error(t, err) + assert.Nil(t, issuer) + }) + } +} + +func TestIssueAndVerifyToken(t *testing.T) { + issuer, verifier, keySet := newIssuerAndVerifier(t) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + require.NoError(t, err) + + assert.Equal(t, testTokenID, issued.ID) + assert.Equal(t, testPrincipalID, issued.PrincipalID) + assert.Equal(t, fixedTime(), issued.IssuedAt) + assert.Equal(t, fixedTime().Add(time.Hour), issued.ExpiresAt) + assert.NotEmpty(t, issued.JWT) + assertProtectedHeader(t, issued.JWT, TokenType, DefaultAlgorithm, testKeyID) + assertNoAuthorizationClaims(t, issued.JWT, keySet) + + verified, err := verifier.VerifyToken(context.Background(), issued.JWT) + require.NoError(t, err) + + assert.Equal(t, testTokenID, verified.ID) + assert.Equal(t, testPrincipalID, verified.PrincipalID) + assert.Equal(t, testIssuer, verified.Issuer) + assert.Equal(t, testAudience, verified.Audience) + assert.Equal(t, fixedTime(), verified.IssuedAt) + assert.Equal(t, fixedTime().Add(time.Hour), verified.ExpiresAt) +} + +func TestIssueTokenRejectsInvalidRequest(t *testing.T) { + issuer, _, _ := newIssuerAndVerifier(t) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{}) + + require.Error(t, err) + assert.Empty(t, issued) +} + +func TestIssueTokenRejectsEmptyGeneratedTokenID(t *testing.T) { + privateKey, _ := newRSAKeyPair(t) + issuer, err := NewIssuer(issuerOptions(privateKey, func(opts *IssuerOptions) { + opts.TokenID = func() (string, error) { + return "", nil + } + })) + require.NoError(t, err) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: testPrincipalID, + }) + + require.Error(t, err) + assert.Empty(t, issued) +} diff --git a/access/jwt/jwt_test.go b/access/jwt/jwt_test.go deleted file mode 100644 index 55f93f3..0000000 --- a/access/jwt/jwt_test.go +++ /dev/null @@ -1,602 +0,0 @@ -package jwt - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "testing" - "time" - - "github.com/lestrrat-go/jwx/v3/jwa" - "github.com/lestrrat-go/jwx/v3/jwk" - "github.com/lestrrat-go/jwx/v3/jws" - jwxjwt "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meigma/authkit" - "github.com/meigma/authkit/authz/role" - "github.com/meigma/authkit/store/memory" -) - -const ( - testIssuer = "https://auth.example.test" - testAudience = "notes-api" - testPrincipalID = "principal_1" - testTokenID = "token-123" - testAction = "note:read" - testRoleID = "reader" - testKeyID = "key-1" - rsaKeyBits = 2048 -) - -func TestIssuerRejectsInvalidOptions(t *testing.T) { - privateKey, _ := newRSAKeyPair(t) - - tests := []struct { - name string - opts IssuerOptions - }{ - { - name: "missing issuer", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.Issuer = "" - }), - }, - { - name: "missing audience", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.Audience = "" - }), - }, - { - name: "missing signing key", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.SigningKey = nil - }), - }, - { - name: "non-positive TTL", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.TTL = 0 - }), - }, - { - name: "signing key without kid", - opts: issuerOptions(newRSAKey(t, "", DefaultAlgorithm), nil), - }, - { - name: "none algorithm", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.Algorithm = jwa.NoSignature().String() - }), - }, - { - name: "symmetric algorithm", - opts: issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.Algorithm = jwa.HS256().String() - }), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - issuer, err := NewIssuer(tt.opts) - - require.Error(t, err) - assert.Nil(t, issuer) - }) - } -} - -func TestVerifierRejectsInvalidOptions(t *testing.T) { - _, publicKey := newRSAKeyPair(t) - keySet := newKeySet(t, publicKey) - - tests := []struct { - name string - opts VerifierOptions - }{ - { - name: "missing issuer", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.Issuer = "" - }), - }, - { - name: "missing audience", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.Audience = "" - }), - }, - { - name: "missing key set", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.KeySet = nil - }), - }, - { - name: "negative skew", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.AcceptableSkew = -time.Second - }), - }, - { - name: "empty algorithm entry", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.AllowedAlgorithms = []string{""} - }), - }, - { - name: "unsupported algorithm", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.AllowedAlgorithms = []string{"unsupported"} - }), - }, - { - name: "symmetric algorithm", - opts: verifierOptions(keySet, func(opts *VerifierOptions) { - opts.AllowedAlgorithms = []string{jwa.HS256().String()} - }), - }, - { - name: "key without kid", - opts: verifierOptions(newKeySet(t, newRSAKey(t, "", DefaultAlgorithm)), nil), - }, - { - name: "key without algorithm", - opts: verifierOptions(newKeySet(t, newRSAKey(t, testKeyID, "")), nil), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verifier, err := NewVerifier(tt.opts) - - require.Error(t, err) - assert.Nil(t, verifier) - }) - } -} - -func TestIssueAndVerifyToken(t *testing.T) { - issuer, verifier, keySet := newIssuerAndVerifier(t) - - issued, err := issuer.IssueToken(context.Background(), IssueRequest{ - PrincipalID: testPrincipalID, - }) - require.NoError(t, err) - - assert.Equal(t, testTokenID, issued.ID) - assert.Equal(t, testPrincipalID, issued.PrincipalID) - assert.Equal(t, fixedTime(), issued.IssuedAt) - assert.Equal(t, fixedTime().Add(time.Hour), issued.ExpiresAt) - assert.NotEmpty(t, issued.JWT) - assertProtectedHeader(t, issued.JWT, TokenType, DefaultAlgorithm, testKeyID) - assertNoAuthorizationClaims(t, issued.JWT, keySet) - - verified, err := verifier.VerifyToken(context.Background(), issued.JWT) - require.NoError(t, err) - - assert.Equal(t, testTokenID, verified.ID) - assert.Equal(t, testPrincipalID, verified.PrincipalID) - assert.Equal(t, testIssuer, verified.Issuer) - assert.Equal(t, testAudience, verified.Audience) - assert.Equal(t, fixedTime(), verified.IssuedAt) - assert.Equal(t, fixedTime().Add(time.Hour), verified.ExpiresAt) -} - -func TestIssueTokenRejectsInvalidRequest(t *testing.T) { - issuer, _, _ := newIssuerAndVerifier(t) - - issued, err := issuer.IssueToken(context.Background(), IssueRequest{}) - - require.Error(t, err) - assert.Empty(t, issued) -} - -func TestIssueTokenRejectsEmptyGeneratedTokenID(t *testing.T) { - privateKey, _ := newRSAKeyPair(t) - issuer, err := NewIssuer(issuerOptions(privateKey, func(opts *IssuerOptions) { - opts.TokenID = func() (string, error) { - return "", nil - } - })) - require.NoError(t, err) - - issued, err := issuer.IssueToken(context.Background(), IssueRequest{ - PrincipalID: testPrincipalID, - }) - - require.Error(t, err) - assert.Empty(t, issued) -} - -func TestVerifyTokenRejectsInvalidTokens(t *testing.T) { - _, verifier, _ := newIssuerAndVerifier(t) - wrongIssuer := issueTokenWithOptions(t, func(opts *IssuerOptions) { - opts.Issuer = "https://other.example.test" - }) - wrongAudience := issueTokenWithOptions(t, func(opts *IssuerOptions) { - opts.Audience = "other-api" - }) - expired := issueTokenWithClock(t, fixedTime().Add(-2*time.Hour)) - - tests := []struct { - name string - token string - }{ - {name: "malformed token", token: "not-a-jwt"}, - {name: "wrong signature", token: issueWithWrongSignature(t)}, - {name: "wrong kid", token: issueWithWrongKeyID(t)}, - {name: "wrong issuer", token: wrongIssuer}, - {name: "wrong audience", token: wrongAudience}, - {name: "expired token", token: expired}, - {name: "missing subject", token: signToken(t, nil, TokenType, DefaultAlgorithm, testKeyID)}, - {name: "wrong typ", token: signToken(t, baseClaims(), "JWT", DefaultAlgorithm, testKeyID)}, - {name: "missing typ", token: signTokenWithoutType(t, baseClaims())}, - {name: "none algorithm", token: unsignedToken(t, baseClaims())}, - {name: "algorithm confusion", token: hmacSignedToken(t, baseClaims())}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - verified, err := verifier.VerifyToken(context.Background(), tt.token) - - require.Error(t, err) - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Empty(t, verified) - }) - } -} - -func TestVerifyTokenRejectsCriticalHeaders(t *testing.T) { - privateKey, publicKey := newRSAKeyPair(t) - verifier, err := NewVerifier(verifierOptions(newKeySet(t, publicKey), nil)) - require.NoError(t, err) - token := signTokenWithHeaders(t, privateKey, baseClaims(), func(headers jws.Headers) { - require.NoError(t, headers.Set("x-authkit-required", true)) - require.NoError(t, headers.Set(jws.CriticalKey, []string{"x-authkit-required"})) - }) - - verified, err := verifier.VerifyToken(context.Background(), token) - - require.ErrorIs(t, err, authkit.ErrUnauthenticated) - assert.Empty(t, verified) -} - -func TestVerifiedTokenUsesStorageBackedAuthorization(t *testing.T) { - issuer, verifier, _ := newIssuerAndVerifier(t) - store := memory.NewStore() - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "publisher", - }) - require.NoError(t, err) - _, err = store.CreateRole(context.Background(), authkit.CreateRoleRequest{ - ID: testRoleID, - DisplayName: "Reader", - }) - require.NoError(t, err) - require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ - RoleID: testRoleID, - Action: testAction, - })) - require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ - PrincipalID: principal.ID, - RoleID: testRoleID, - })) - - issued, err := issuer.IssueToken(context.Background(), IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - verified, err := verifier.VerifyToken(context.Background(), issued.JWT) - require.NoError(t, err) - loaded, err := store.FindPrincipal(context.Background(), verified.PrincipalID) - require.NoError(t, err) - - authorizer, err := role.NewAuthorizer(store) - require.NoError(t, err) - decision, err := authorizer.Can(context.Background(), authkit.AuthorizationCheck{ - Principal: loaded, - Action: testAction, - Resource: authkit.Resource{Type: "note", ID: "note-1"}, - }) - - require.NoError(t, err) - assert.True(t, decision.Allowed) -} - -func newIssuerAndVerifier(t *testing.T) (*Issuer, *Verifier, jwk.Set) { - t.Helper() - - privateKey, publicKey := newRSAKeyPair(t) - keySet := newKeySet(t, publicKey) - issuer, err := NewIssuer(issuerOptions(privateKey, nil)) - require.NoError(t, err) - verifier, err := NewVerifier(verifierOptions(keySet, nil)) - require.NoError(t, err) - - return issuer, verifier, keySet -} - -func issuerOptions(signingKey jwk.Key, mutate func(*IssuerOptions)) IssuerOptions { - opts := IssuerOptions{ - Issuer: testIssuer, - Audience: testAudience, - TTL: time.Hour, - SigningKey: signingKey, - Clock: fixedTime, - TokenID: func() (string, error) { - return testTokenID, nil - }, - } - if mutate != nil { - mutate(&opts) - } - - return opts -} - -func verifierOptions(keySet jwk.Set, mutate func(*VerifierOptions)) VerifierOptions { - opts := VerifierOptions{ - Issuer: testIssuer, - Audience: testAudience, - KeySet: keySet, - Clock: fixedTime, - } - if mutate != nil { - mutate(&opts) - } - - return opts -} - -func issueToken(t *testing.T, issuer *Issuer) IssuedToken { - t.Helper() - - issued, err := issuer.IssueToken(context.Background(), IssueRequest{ - PrincipalID: testPrincipalID, - }) - require.NoError(t, err) - - return issued -} - -func issueTokenWithOptions(t *testing.T, mutate func(*IssuerOptions)) string { - t.Helper() - - privateKey, _ := newRSAKeyPair(t) - issuer, err := NewIssuer(issuerOptions(privateKey, mutate)) - require.NoError(t, err) - - return issueToken(t, issuer).JWT -} - -func issueTokenWithClock(t *testing.T, now time.Time) string { - t.Helper() - - return issueTokenWithOptions(t, func(opts *IssuerOptions) { - opts.Clock = func() time.Time { - return now - } - }) -} - -func issueWithWrongSignature(t *testing.T) string { - t.Helper() - - privateKey, _ := newRSAKeyPair(t) - issuer, err := NewIssuer(issuerOptions(privateKey, nil)) - require.NoError(t, err) - - return issueToken(t, issuer).JWT -} - -func issueWithWrongKeyID(t *testing.T) string { - t.Helper() - - return issueTokenWithOptions(t, func(opts *IssuerOptions) { - opts.SigningKey = newRSAKey(t, "other-key", DefaultAlgorithm) - }) -} - -func signToken( - t *testing.T, - claims map[string]any, - tokenType string, - algorithmName string, - keyID string, -) string { - t.Helper() - - key := newRSAKey(t, keyID, algorithmName) - algorithm, err := signatureAlgorithm(algorithmName) - require.NoError(t, err) - token := jwxjwt.New() - for name, value := range claims { - require.NoError(t, token.Set(name, value)) - } - headers := jws.NewHeaders() - require.NoError(t, headers.Set(jws.TypeKey, tokenType)) - signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) - require.NoError(t, err) - - return string(signed) -} - -func signTokenWithHeaders( - t *testing.T, - key jwk.Key, - claims map[string]any, - mutate func(jws.Headers), -) string { - t.Helper() - - algorithm, err := signatureAlgorithm(DefaultAlgorithm) - require.NoError(t, err) - token := jwxjwt.New() - for name, value := range claims { - require.NoError(t, token.Set(name, value)) - } - headers := jws.NewHeaders() - require.NoError(t, headers.Set(jws.TypeKey, TokenType)) - if mutate != nil { - mutate(headers) - } - signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(algorithm, key, jws.WithProtectedHeaders(headers))) - require.NoError(t, err) - - return string(signed) -} - -func signTokenWithoutType(t *testing.T, claims map[string]any) string { - t.Helper() - - token := jwxjwt.New() - for name, value := range claims { - require.NoError(t, token.Set(name, value)) - } - payload, err := json.Marshal(token) - require.NoError(t, err) - - key := newRSAKey(t, testKeyID, DefaultAlgorithm) - signed, err := jws.Sign(payload, jws.WithKey(jwa.RS256(), key)) - require.NoError(t, err) - - return string(signed) -} - -func unsignedToken(t *testing.T, claims map[string]any) string { - t.Helper() - - header := map[string]any{ - jws.AlgorithmKey: jwa.NoSignature().String(), - jws.TypeKey: TokenType, - } - headerBytes, err := json.Marshal(header) - require.NoError(t, err) - payloadBytes, err := json.Marshal(claims) - require.NoError(t, err) - - return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + - base64.RawURLEncoding.EncodeToString(payloadBytes) + "." -} - -func hmacSignedToken(t *testing.T, claims map[string]any) string { - t.Helper() - - key, err := jwk.Import([]byte("secret")) - require.NoError(t, err) - require.NoError(t, key.Set(jwk.KeyIDKey, testKeyID)) - token := jwxjwt.New() - for name, value := range claims { - require.NoError(t, token.Set(name, value)) - } - headers := jws.NewHeaders() - require.NoError(t, headers.Set(jws.TypeKey, TokenType)) - signed, err := jwxjwt.Sign(token, jwxjwt.WithKey(jwa.HS256(), key, jws.WithProtectedHeaders(headers))) - require.NoError(t, err) - - return string(signed) -} - -func baseClaims() map[string]any { - now := fixedTime() - - return map[string]any{ - jwxjwt.IssuerKey: testIssuer, - jwxjwt.SubjectKey: testPrincipalID, - jwxjwt.AudienceKey: []string{testAudience}, - jwxjwt.IssuedAtKey: now, - jwxjwt.ExpirationKey: now.Add(time.Hour), - jwxjwt.JwtIDKey: testTokenID, - } -} - -func assertProtectedHeader(t *testing.T, plaintext string, tokenType string, algorithm string, keyID string) { - t.Helper() - - message, err := jws.Parse([]byte(plaintext), jws.WithCompact()) - require.NoError(t, err) - require.Len(t, message.Signatures(), 1) - headers := message.Signatures()[0].ProtectedHeaders() - require.NotNil(t, headers) - - gotType, ok := headers.Type() - require.True(t, ok) - assert.Equal(t, tokenType, gotType) - gotAlgorithm, ok := headers.Algorithm() - require.True(t, ok) - assert.Equal(t, algorithm, gotAlgorithm.String()) - gotKeyID, ok := headers.KeyID() - require.True(t, ok) - assert.Equal(t, keyID, gotKeyID) -} - -func assertNoAuthorizationClaims(t *testing.T, plaintext string, keySet jwk.Set) { - t.Helper() - - token, err := jwxjwt.Parse( - []byte(plaintext), - jwxjwt.WithKeySet(keySet), - jwxjwt.WithIssuer(testIssuer), - jwxjwt.WithAudience(testAudience), - jwxjwt.WithClock(jwxjwt.ClockFunc(fixedTime)), - ) - require.NoError(t, err) - assert.ElementsMatch(t, []string{ - jwxjwt.AudienceKey, - jwxjwt.ExpirationKey, - jwxjwt.IssuedAtKey, - jwxjwt.IssuerKey, - jwxjwt.JwtIDKey, - jwxjwt.SubjectKey, - }, token.Keys()) - assert.False(t, token.Has("roles")) - assert.False(t, token.Has("permissions")) - assert.False(t, token.Has("actions")) -} - -func newRSAKeyPair(t *testing.T) (jwk.Key, jwk.Key) { - t.Helper() - - privateKey := newRSAKey(t, testKeyID, DefaultAlgorithm) - publicKey, err := jwk.PublicKeyOf(privateKey) - require.NoError(t, err) - - return privateKey, publicKey -} - -func newRSAKey(t *testing.T, keyID string, algorithm string) jwk.Key { - t.Helper() - - rawKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) - require.NoError(t, err) - key, err := jwk.Import(rawKey) - require.NoError(t, err) - if keyID != "" { - require.NoError(t, key.Set(jwk.KeyIDKey, keyID)) - } - if algorithm != "" { - require.NoError(t, key.Set(jwk.AlgorithmKey, algorithm)) - } - - return key -} - -func newKeySet(t *testing.T, key jwk.Key) jwk.Set { - t.Helper() - - keySet := jwk.NewSet() - require.NoError(t, keySet.AddKey(key)) - - return keySet -} - -func fixedTime() time.Time { - return time.Date(2026, time.May, 13, 21, 0, 0, 0, time.UTC) -} diff --git a/access/jwt/verifier_test.go b/access/jwt/verifier_test.go new file mode 100644 index 0000000..a7f4030 --- /dev/null +++ b/access/jwt/verifier_test.go @@ -0,0 +1,182 @@ +package jwt + +import ( + "context" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/authz/role" + "github.com/meigma/authkit/store/memory" +) + +func TestVerifierRejectsInvalidOptions(t *testing.T) { + _, publicKey := newRSAKeyPair(t) + keySet := newKeySet(t, publicKey) + + tests := []struct { + name string + opts VerifierOptions + }{ + { + name: "missing issuer", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.Issuer = "" + }), + }, + { + name: "missing audience", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.Audience = "" + }), + }, + { + name: "missing key set", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.KeySet = nil + }), + }, + { + name: "negative skew", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AcceptableSkew = -time.Second + }), + }, + { + name: "empty algorithm entry", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{""} + }), + }, + { + name: "unsupported algorithm", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{"unsupported"} + }), + }, + { + name: "symmetric algorithm", + opts: verifierOptions(keySet, func(opts *VerifierOptions) { + opts.AllowedAlgorithms = []string{jwa.HS256().String()} + }), + }, + { + name: "key without kid", + opts: verifierOptions(newKeySet(t, newRSAKey(t, "", DefaultAlgorithm)), nil), + }, + { + name: "key without algorithm", + opts: verifierOptions(newKeySet(t, newRSAKey(t, testKeyID, "")), nil), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier, err := NewVerifier(tt.opts) + + require.Error(t, err) + assert.Nil(t, verifier) + }) + } +} + +func TestVerifyTokenRejectsInvalidTokens(t *testing.T) { + _, verifier, _ := newIssuerAndVerifier(t) + wrongIssuer := issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Issuer = "https://other.example.test" + }) + wrongAudience := issueTokenWithOptions(t, func(opts *IssuerOptions) { + opts.Audience = "other-api" + }) + expired := issueTokenWithClock(t, fixedTime().Add(-2*time.Hour)) + + tests := []struct { + name string + token string + }{ + {name: "malformed token", token: "not-a-jwt"}, + {name: "wrong signature", token: issueWithWrongSignature(t)}, + {name: "wrong kid", token: issueWithWrongKeyID(t)}, + {name: "wrong issuer", token: wrongIssuer}, + {name: "wrong audience", token: wrongAudience}, + {name: "expired token", token: expired}, + {name: "missing subject", token: signToken(t, nil, TokenType, DefaultAlgorithm, testKeyID)}, + {name: "wrong typ", token: signToken(t, baseClaims(), "JWT", DefaultAlgorithm, testKeyID)}, + {name: "missing typ", token: signTokenWithoutType(t, baseClaims())}, + {name: "none algorithm", token: unsignedToken(t, baseClaims())}, + {name: "algorithm confusion", token: hmacSignedToken(t, baseClaims())}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verified, err := verifier.VerifyToken(context.Background(), tt.token) + + require.Error(t, err) + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, verified) + }) + } +} + +func TestVerifyTokenRejectsCriticalHeaders(t *testing.T) { + privateKey, publicKey := newRSAKeyPair(t) + verifier, err := NewVerifier(verifierOptions(newKeySet(t, publicKey), nil)) + require.NoError(t, err) + token := signTokenWithHeaders(t, privateKey, baseClaims(), func(headers jws.Headers) { + require.NoError(t, headers.Set("x-authkit-required", true)) + require.NoError(t, headers.Set(jws.CriticalKey, []string{"x-authkit-required"})) + }) + + verified, err := verifier.VerifyToken(context.Background(), token) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, verified) +} + +func TestVerifiedTokenUsesStorageBackedAuthorization(t *testing.T) { + issuer, verifier, _ := newIssuerAndVerifier(t) + store := memory.NewStore() + principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindService, + DisplayName: "publisher", + }) + require.NoError(t, err) + _, err = store.CreateRole(context.Background(), authkit.CreateRoleRequest{ + ID: testRoleID, + DisplayName: "Reader", + }) + require.NoError(t, err) + require.NoError(t, store.GrantRoleAction(context.Background(), authkit.GrantRoleActionRequest{ + RoleID: testRoleID, + Action: testAction, + })) + require.NoError(t, store.AssignPrincipalRole(context.Background(), authkit.AssignPrincipalRoleRequest{ + PrincipalID: principal.ID, + RoleID: testRoleID, + })) + + issued, err := issuer.IssueToken(context.Background(), IssueRequest{ + PrincipalID: principal.ID, + }) + require.NoError(t, err) + verified, err := verifier.VerifyToken(context.Background(), issued.JWT) + require.NoError(t, err) + loaded, err := store.FindPrincipal(context.Background(), verified.PrincipalID) + require.NoError(t, err) + + authorizer, err := role.NewAuthorizer(store) + require.NoError(t, err) + decision, err := authorizer.Can(context.Background(), authkit.AuthorizationCheck{ + Principal: loaded, + Action: testAction, + Resource: authkit.Resource{Type: "note", ID: "note-1"}, + }) + + require.NoError(t, err) + assert.True(t, decision.Allowed) +} From 1f1f001e6d5e9fac97dbdba4f968d4afb5a9e256 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:33:47 -0700 Subject: [PATCH 4/7] refactor(access/middleware): document private helpers and assert the principal-authenticator port - Add a compile-time assertion that *Authenticator satisfies authkit.PrincipalAuthenticator, making the port relationship explicit rather than relying on duck typing. - Document bearerToken (note RFC 7235 case-insensitive scheme) and unauthenticated. - Annotate the ErrPrincipalNotFound branch as a security-relevant existence-leak defence and the post-finder principal.ID == "" branch as a defence against a misbehaving PrincipalFinder. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/middleware/authenticator.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/access/middleware/authenticator.go b/access/middleware/authenticator.go index 13391a1..20a573a 100644 --- a/access/middleware/authenticator.go +++ b/access/middleware/authenticator.go @@ -18,6 +18,9 @@ const ( bearerScheme = "Bearer" ) +// Compile-time assertion that *Authenticator satisfies the authkit.PrincipalAuthenticator port. +var _ authkit.PrincipalAuthenticator = (*Authenticator)(nil) + // Authenticator verifies authkit access JWT bearer tokens from HTTP requests. type Authenticator struct { verifier *jwt.Verifier @@ -70,12 +73,16 @@ func (a *Authenticator) AuthenticatePrincipal( } principal, err := a.principalFinder.FindPrincipal(ctx, verified.PrincipalID) + // Missing principal collapses to the generic unauthenticated response so the API does not + // leak whether a specific PrincipalID exists. if errors.Is(err, authkit.ErrPrincipalNotFound) { return nil, unauthenticated("principal not found") } if err != nil { return nil, fmt.Errorf("%w: find principal: %w", authkit.ErrInternal, err) } + // Defend against a misbehaving PrincipalFinder that returns (zero-value, nil); a principal + // with no ID would bypass downstream authorization that keys off Principal.ID. if principal.ID == "" { return nil, fmt.Errorf("%w: principal finder returned principal without ID", authkit.ErrInternal) } @@ -85,6 +92,8 @@ func (a *Authenticator) AuthenticatePrincipal( }, nil } +// bearerToken extracts the credential portion of an HTTP Authorization: Bearer header. +// The scheme comparison is case-insensitive per RFC 7235 §2.1. func bearerToken(req *http.Request) (string, error) { header := req.Header.Get("Authorization") parts := strings.Fields(header) @@ -95,6 +104,8 @@ func bearerToken(req *http.Request) (string, error) { return parts[1], nil } +// unauthenticated wraps reason with authkit.ErrUnauthenticated so callers can recognise the +// failure via errors.Is. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) } From 76085ec813b8e341a08abb6392c8bcd28d116131 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:36:18 -0700 Subject: [PATCH 5/7] chore: introduce mockery for port mocks, starting with authkit.PrincipalFinder The go-testing skill mandates mockery at port boundaries. This pass introduces the tooling so consumer-package refactors (starting with access/middleware, eventually exchange/ and http/compose/) can use generated mocks instead of standing up real storage adapters. - Add mockery v3.7.0 as a go.mod tool dependency so generation works through `go tool mockery` with no extra system install. - .mockery.yaml at the repo root drives generation; today it only configures authkit.PrincipalFinder. Future passes append new interfaces here. - Generated mock lands at mocks/authkit/principal_finder.go in package authkitmocks. - Add a `generate` task to moon.yml. CI is not yet wired to fail on drift; that's a follow-up once more mocks land and the policy is worth enforcing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .mockery.yaml | 10 +++ go.mod | 37 ++++++++++- go.sum | 77 +++++++++++++++++++++- mocks/authkit/principal_finder.go | 105 ++++++++++++++++++++++++++++++ moon.yml | 9 +++ 5 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 .mockery.yaml create mode 100644 mocks/authkit/principal_finder.go diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..6c2ebb7 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,10 @@ +packages: + github.com/meigma/authkit: + interfaces: + PrincipalFinder: + config: + template: testify + structname: PrincipalFinder + pkgname: authkitmocks + dir: mocks/authkit + filename: principal_finder.go diff --git a/go.mod b/go.mod index b65e14e..e9bdd4d 100644 --- a/go.mod +++ b/go.mod @@ -20,21 +20,25 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect + github.com/brunoga/deep v1.3.1 // indirect github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -45,10 +49,20 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jedib0t/go-pretty/v6 v6.7.8 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/parsers/yaml v1.1.0 // indirect + github.com/knadh/koanf/providers/env v1.1.0 // indirect + github.com/knadh/koanf/providers/file v1.2.1 // indirect + github.com/knadh/koanf/providers/posflag v1.0.1 // indirect + github.com/knadh/koanf/providers/structs v1.0.0 // indirect + github.com/knadh/koanf/v2 v2.3.2 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.2.1 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect @@ -57,6 +71,11 @@ require ( github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.2 // indirect @@ -69,29 +88,43 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect + github.com/vektra/mockery/v3 v3.7.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool github.com/vektra/mockery/v3 diff --git a/go.sum b/go.sum index a52b460..231df79 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/brunoga/deep v1.3.1 h1:bSrL6FhAZa6JlVv4vsi7Hg8SLwroDb1kgDERRVipBCo= +github.com/brunoga/deep v1.3.1/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= github.com/casbin/casbin/v3 v3.10.0 h1:039ORla55vCeIZWd0LfzWFt1yiEA5X4W41xBW2bQuHs= github.com/casbin/casbin/v3 v3.10.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= @@ -20,6 +22,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -28,13 +32,15 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -45,8 +51,12 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -64,6 +74,7 @@ github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= @@ -77,6 +88,10 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -85,8 +100,24 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= +github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= +github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -111,8 +142,21 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -137,18 +181,29 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= @@ -169,8 +224,17 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vektra/mockery/v3 v3.7.0 h1:Dd0EeaOcRJBVP9n3oYOVPV7KdPaaE3EcwTppaZIsFSM= +github.com/vektra/mockery/v3 v3.7.0/go.mod h1:z9Wr23Ha8etImqQwS3boTNR9WkjX6tIklW5c88DRkSw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -195,17 +259,24 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= diff --git a/mocks/authkit/principal_finder.go b/mocks/authkit/principal_finder.go new file mode 100644 index 0000000..5852acd --- /dev/null +++ b/mocks/authkit/principal_finder.go @@ -0,0 +1,105 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package authkitmocks + +import ( + "context" + + "github.com/meigma/authkit" + mock "github.com/stretchr/testify/mock" +) + +// NewPrincipalFinder creates a new instance of PrincipalFinder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPrincipalFinder(t interface { + mock.TestingT + Cleanup(func()) +}) *PrincipalFinder { + mock := &PrincipalFinder{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// PrincipalFinder is an autogenerated mock type for the PrincipalFinder type +type PrincipalFinder struct { + mock.Mock +} + +type PrincipalFinder_Expecter struct { + mock *mock.Mock +} + +func (_m *PrincipalFinder) EXPECT() *PrincipalFinder_Expecter { + return &PrincipalFinder_Expecter{mock: &_m.Mock} +} + +// FindPrincipal provides a mock function for the type PrincipalFinder +func (_mock *PrincipalFinder) FindPrincipal(ctx context.Context, id string) (authkit.Principal, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for FindPrincipal") + } + + var r0 authkit.Principal + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (authkit.Principal, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) authkit.Principal); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(authkit.Principal) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// PrincipalFinder_FindPrincipal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindPrincipal' +type PrincipalFinder_FindPrincipal_Call struct { + *mock.Call +} + +// FindPrincipal is a helper method to define mock.On call +// - ctx context.Context +// - id string +func (_e *PrincipalFinder_Expecter) FindPrincipal(ctx interface{}, id interface{}) *PrincipalFinder_FindPrincipal_Call { + return &PrincipalFinder_FindPrincipal_Call{Call: _e.mock.On("FindPrincipal", ctx, id)} +} + +func (_c *PrincipalFinder_FindPrincipal_Call) Run(run func(ctx context.Context, id string)) *PrincipalFinder_FindPrincipal_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *PrincipalFinder_FindPrincipal_Call) Return(principal authkit.Principal, err error) *PrincipalFinder_FindPrincipal_Call { + _c.Call.Return(principal, err) + return _c +} + +func (_c *PrincipalFinder_FindPrincipal_Call) RunAndReturn(run func(ctx context.Context, id string) (authkit.Principal, error)) *PrincipalFinder_FindPrincipal_Call { + _c.Call.Return(run) + return _c +} diff --git a/moon.yml b/moon.yml index 1f36dcf..5b3f27f 100644 --- a/moon.yml +++ b/moon.yml @@ -35,6 +35,15 @@ fileGroups: - '.release-please-manifest.json' tasks: + generate: + command: 'go tool mockery' + toolchains: ['go'] + inputs: + - '@group(go)' + - '.mockery.yaml' + options: + cache: false + build: command: 'go build ./...' toolchains: ['go'] From c518e8d80e7cc257af4a9f89f9e408bdbc4cec53 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:38:25 -0700 Subject: [PATCH 6/7] test(access/middleware): mock PrincipalFinder and add missing rejection cases - Replace memory.Store with the generated authkitmocks.PrincipalFinder so the test asserts the Authenticator's contract with its port, not the store's behaviour. - Collapse plumbing into a testContext fixture per the go-testing skill's pattern. - Add three rejection cases the prior suite missed: * cancelled context returns context.Canceled untouched * nil request returns ErrUnauthenticated * principal finder returning a generic error wraps with ErrInternal - Add coverage for the principal.ID == "" defensive branch in AuthenticatePrincipal. Issuer and Verifier remain real concrete adapters (they are pure domain logic with no port boundary). Per-case "wrong issuer / wrong audience / expired" tokens reuse the shared signing key so the rejection happens at iss/aud/exp validation, not signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/middleware/authenticator_test.go | 197 +++++++++++++++--------- 1 file changed, 120 insertions(+), 77 deletions(-) diff --git a/access/middleware/authenticator_test.go b/access/middleware/authenticator_test.go index 73bd566..6f7207a 100644 --- a/access/middleware/authenticator_test.go +++ b/access/middleware/authenticator_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "errors" "net/http" "net/http/httptest" "testing" @@ -12,12 +13,13 @@ import ( "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/meigma/authkit" "github.com/meigma/authkit/access/jwt" "github.com/meigma/authkit/access/middleware" - "github.com/meigma/authkit/store/memory" + authkitmocks "github.com/meigma/authkit/mocks/authkit" ) const ( @@ -26,25 +28,19 @@ const ( testKeyID = "key-1" testPrincipalID = "principal_1" testTokenID = "token-123" + rsaKeyBits = 2048 ) func TestNewAuthenticatorValidatesDependencies(t *testing.T) { - _, verifier := newIssuerAndVerifier(t) - store := memory.NewStore() + tc := newTestContext(t) tests := []struct { name string verifier *jwt.Verifier principalFinder authkit.PrincipalFinder }{ - { - name: "missing verifier", - principalFinder: store, - }, - { - name: "missing principal finder", - verifier: verifier, - }, + {name: "missing verifier", principalFinder: tc.finder}, + {name: "missing principal finder", verifier: tc.verifier}, } for _, tt := range tests { @@ -58,68 +54,38 @@ func TestNewAuthenticatorValidatesDependencies(t *testing.T) { } func TestAuthenticatorAuthenticatesAccessJWT(t *testing.T) { - issuer, verifier := newIssuerAndVerifier(t) - store := memory.NewStore() - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ + tc := newTestContext(t) + principal := authkit.Principal{ + ID: testPrincipalID, Kind: authkit.PrincipalKindService, DisplayName: "notes service", - }) - require.NoError(t, err) - authenticator, err := middleware.NewAuthenticator(verifier, store) - require.NoError(t, err) - issued, err := issuer.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - req := requestWithBearer(issued.JWT) + } + tc.finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(principal, nil) + issued := tc.issue(t, nil) - authentication, err := authenticator.AuthenticatePrincipal(context.Background(), req) + authentication, err := tc.authenticator.AuthenticatePrincipal(context.Background(), requestWithBearer(issued.JWT)) require.NoError(t, err) - assert.Equal(t, middleware.Name, authenticator.Name()) + assert.Equal(t, middleware.Name, tc.authenticator.Name()) require.NotNil(t, authentication) assert.Equal(t, principal, authentication.Principal) } func TestAuthenticatorRejectsInvalidRequests(t *testing.T) { - privateKey, publicKey := newRSAKeyPair(t) - issuer := newIssuer(t, privateKey, fixedTime(), nil) - verifier := newVerifier(t, publicKey) - store := memory.NewStore() - principal, err := store.CreatePrincipal(context.Background(), authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindService, - DisplayName: "notes service", + tc := newTestContext(t) + valid := tc.issue(t, nil) + expired := tc.issue(t, func(opts *jwt.IssuerOptions) { + opts.Clock = staticClock(fixedTime().Add(-2 * time.Hour)) }) - require.NoError(t, err) - valid, err := issuer.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - expiredIssuer := newIssuer(t, privateKey, fixedTime().Add(-2*time.Hour), nil) - expired, err := expiredIssuer.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - wrongIssuer := newIssuer(t, privateKey, fixedTime(), func(opts *jwt.IssuerOptions) { + wrongIssuer := tc.issue(t, func(opts *jwt.IssuerOptions) { opts.Issuer = "https://other.example.test" }) - wrongIssuerToken, err := wrongIssuer.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - wrongAudience := newIssuer(t, privateKey, fixedTime(), func(opts *jwt.IssuerOptions) { + wrongAudience := tc.issue(t, func(opts *jwt.IssuerOptions) { opts.Audience = "other-api" }) - wrongAudienceToken, err := wrongAudience.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: principal.ID, - }) - require.NoError(t, err) - missingPrincipal, err := issuer.IssueToken(context.Background(), jwt.IssueRequest{ - PrincipalID: "missing", - }) - require.NoError(t, err) - authenticator, err := middleware.NewAuthenticator(verifier, store) - require.NoError(t, err) + missingPrincipalToken := tc.issueFor(t, "missing", nil) + tc.finder.EXPECT().FindPrincipal(mock.Anything, "missing"). + Return(authkit.Principal{}, authkit.ErrPrincipalNotFound).Maybe() tests := []struct { name string @@ -129,14 +95,15 @@ func TestAuthenticatorRejectsInvalidRequests(t *testing.T) { {name: "malformed bearer", req: requestWithAuthorization("Basic " + valid.JWT)}, {name: "invalid JWT", req: requestWithBearer("not-a-jwt")}, {name: "expired JWT", req: requestWithBearer(expired.JWT)}, - {name: "wrong issuer", req: requestWithBearer(wrongIssuerToken.JWT)}, - {name: "wrong audience", req: requestWithBearer(wrongAudienceToken.JWT)}, - {name: "missing principal", req: requestWithBearer(missingPrincipal.JWT)}, + {name: "wrong issuer", req: requestWithBearer(wrongIssuer.JWT)}, + {name: "wrong audience", req: requestWithBearer(wrongAudience.JWT)}, + {name: "missing principal", req: requestWithBearer(missingPrincipalToken.JWT)}, + {name: "nil request"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authentication, err := authenticator.AuthenticatePrincipal(context.Background(), tt.req) + authentication, err := tc.authenticator.AuthenticatePrincipal(context.Background(), tt.req) require.ErrorIs(t, err, authkit.ErrUnauthenticated) assert.Nil(t, authentication) @@ -144,37 +111,109 @@ func TestAuthenticatorRejectsInvalidRequests(t *testing.T) { } } -func newIssuerAndVerifier(t *testing.T) (*jwt.Issuer, *jwt.Verifier) { +func TestAuthenticatorReturnsContextError(t *testing.T) { + tc := newTestContext(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + authentication, err := tc.authenticator.AuthenticatePrincipal(ctx, requestWithBearer("ignored")) + + require.ErrorIs(t, err, context.Canceled) + assert.Nil(t, authentication) +} + +func TestAuthenticatorWrapsInternalFinderError(t *testing.T) { + tc := newTestContext(t) + finderErr := errors.New("database connection refused") + tc.finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID). + Return(authkit.Principal{}, finderErr) + issued := tc.issue(t, nil) + + authentication, err := tc.authenticator.AuthenticatePrincipal(context.Background(), requestWithBearer(issued.JWT)) + + require.Error(t, err) + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, finderErr) + assert.Nil(t, authentication) +} + +func TestAuthenticatorRejectsPrincipalWithoutID(t *testing.T) { + tc := newTestContext(t) + tc.finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID). + Return(authkit.Principal{DisplayName: "no id"}, nil) + issued := tc.issue(t, nil) + + authentication, err := tc.authenticator.AuthenticatePrincipal(context.Background(), requestWithBearer(issued.JWT)) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Nil(t, authentication) +} + +// testContext collapses the canonical issuer, verifier, mocked principal finder, and +// authenticator into one fixture so each test only configures what's behaviourally relevant. +// The signing key is shared so per-test "wrong issuer" / "wrong audience" / "expired" tokens +// produce valid signatures that still mismatch the canonical verifier's expectations. +type testContext struct { + signingKey jwk.Key + verifier *jwt.Verifier + finder *authkitmocks.PrincipalFinder + authenticator *middleware.Authenticator +} + +func newTestContext(t *testing.T) *testContext { + t.Helper() + + signingKey, publicKey := newRSAKeyPair(t) + verifier := newVerifier(t, publicKey) + finder := authkitmocks.NewPrincipalFinder(t) + authenticator, err := middleware.NewAuthenticator(verifier, finder) + require.NoError(t, err) + + return &testContext{ + signingKey: signingKey, + verifier: verifier, + finder: finder, + authenticator: authenticator, + } +} + +func (tc *testContext) issue(t *testing.T, mutate func(*jwt.IssuerOptions)) jwt.IssuedToken { t.Helper() - privateKey, publicKey := newRSAKeyPair(t) - return newIssuer(t, privateKey, fixedTime(), nil), newVerifier(t, publicKey) + return tc.issueFor(t, testPrincipalID, mutate) } -func newIssuer( +func (tc *testContext) issueFor( t *testing.T, - privateKey jwk.Key, - now time.Time, + principalID string, mutate func(*jwt.IssuerOptions), -) *jwt.Issuer { +) jwt.IssuedToken { t.Helper() - issuerOpts := jwt.IssuerOptions{ + issuer := newIssuer(t, tc.signingKey, mutate) + issued, err := issuer.IssueToken(context.Background(), jwt.IssueRequest{PrincipalID: principalID}) + require.NoError(t, err) + + return issued +} + +func newIssuer(t *testing.T, signingKey jwk.Key, mutate func(*jwt.IssuerOptions)) *jwt.Issuer { + t.Helper() + + opts := jwt.IssuerOptions{ Issuer: testIssuer, Audience: testAudience, TTL: time.Hour, - SigningKey: privateKey, - Clock: func() time.Time { - return now - }, + SigningKey: signingKey, + Clock: fixedTime, TokenID: func() (string, error) { return testTokenID, nil }, } if mutate != nil { - mutate(&issuerOpts) + mutate(&opts) } - issuer, err := jwt.NewIssuer(issuerOpts) + issuer, err := jwt.NewIssuer(opts) require.NoError(t, err) return issuer @@ -199,7 +238,7 @@ func newVerifier(t *testing.T, publicKey jwk.Key) *jwt.Verifier { func newRSAKeyPair(t *testing.T) (jwk.Key, jwk.Key) { t.Helper() - rawKey, err := rsa.GenerateKey(rand.Reader, 2048) + rawKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) require.NoError(t, err) privateKey, err := jwk.Import(rawKey) require.NoError(t, err) @@ -222,6 +261,10 @@ func requestWithAuthorization(header string) *http.Request { return req } +func staticClock(now time.Time) func() time.Time { + return func() time.Time { return now } +} + func fixedTime() time.Time { return time.Date(2026, time.May, 13, 22, 0, 0, 0, time.UTC) } From 59bdc8ea33edc64da220af8cb4934237aec6f186 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Wed, 27 May 2026 13:39:12 -0700 Subject: [PATCH 7/7] style: link errors.Is in unauthenticated godocs godoclint requires [errors.Is] form so pkg.go.dev renders a hyperlink. Co-Authored-By: Claude Opus 4.7 (1M context) --- access/jwt/verifier.go | 2 +- access/middleware/authenticator.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/access/jwt/verifier.go b/access/jwt/verifier.go index 7770120..e5d0cb4 100644 --- a/access/jwt/verifier.go +++ b/access/jwt/verifier.go @@ -188,7 +188,7 @@ func (v *Verifier) verifiedToken(token jwxjwt.Token) (VerifiedToken, error) { } // unauthenticated wraps reason with authkit.ErrUnauthenticated so callers can recognise the failure -// via errors.Is. +// via [errors.Is]. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) } diff --git a/access/middleware/authenticator.go b/access/middleware/authenticator.go index 20a573a..f7a6ce8 100644 --- a/access/middleware/authenticator.go +++ b/access/middleware/authenticator.go @@ -105,7 +105,7 @@ func bearerToken(req *http.Request) (string, error) { } // unauthenticated wraps reason with authkit.ErrUnauthenticated so callers can recognise the -// failure via errors.Is. +// failure via [errors.Is]. func unauthenticated(reason string) error { return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) }