Skip to content

feat(oauth): JWT-Bearer client auth + grant (RFC 7523) + token-exchange polish - #65

Merged
thegdsks merged 3 commits into
mainfrom
feat/jwt-bearer-grant-2026-06-22
Jun 22, 2026
Merged

feat(oauth): JWT-Bearer client auth + grant (RFC 7523) + token-exchange polish#65
thegdsks merged 3 commits into
mainfrom
feat/jwt-bearer-grant-2026-06-22

Conversation

@thegdsks

Copy link
Copy Markdown
Member

Summary

  • RFC 7523 section 2.2: private_key_jwt and client_secret_jwt client authentication. Clients present a signed JWT client_assertion instead of a shared secret; the AS verifies against the client's registered jwks_uri or inline jwks. Supports ES256, ES384, RS256, PS256, EdDSA (stdlib-only, no new deps).
  • RFC 7523 section 2.1: urn:ietf:params:oauth:grant-type:jwt-bearer grant. Exchanges an externally-issued JWT (GCP SA, k8s OIDC, AWS IAM Roles Anywhere) for an AS-issued access token via configured TrustedJWTIssuers. Includes SubjectMapper interface with built-in SubMapper (sub=ULID) and EmailMapper (email claim).
  • Token-exchange polish: JWTBearerConfig.MaxActorChainDepth overrides AgentPolicy.MaxChainDepth when set; ExchangeToken validates the audience parameter against the configured resource catalog.
  • JTI replay prevention: in-process sync.Map fallback + optional durable JWTBearerStorage interface (InsertJTI, SweepExpiredJTIs) with memory.Store implementation.
  • AS metadata: advertises private_key_jwt, client_secret_jwt, token_endpoint_auth_signing_alg_values_supported, and the jwt-bearer grant URN.
  • 10 new tests (all passing -race -count=3): TestPrivateKeyJWTHappyPath, TestPrivateKeyJWTReplayRejected, TestPrivateKeyJWTExpiredAssertion, TestPrivateKeyJWTWrongAudience, TestPrivateKeyJWTAlgorithmDowngradeRejected, TestJWTBearerGrantHappyPath, TestJWTBearerGrantUntrustedIssuer, TestJWTBearerGrantSubjectMapperMiss, TestTokenExchangeMaxChainDepth, TestTokenExchangeAudienceWhitelist.

Test plan

  • go test -race -count=3 ./... passes (verified locally)
  • gofmt -l . returns no output (verified)
  • go vet ./... returns no output (verified)
  • No mcpresource files touched (verified via git diff --name-only)
  • No em dashes in any committed file (verified via unicode grep)
  • Happy path: private_key_jwt client authenticates with ES256 assertion and receives access token
  • Replay: presenting the same jti twice returns an error on the second attempt
  • Expired assertion: exp in the past returns an error
  • Wrong audience: assertion aud not matching the token endpoint URL returns an error
  • Algorithm downgrade: HS256 assertion is rejected for private_key_jwt clients
  • JWT bearer grant: externally-issued JWT from trusted issuer exchanges for AS token
  • Untrusted issuer: assertion from unknown iss is rejected
  • Subject mapper miss: EmailMapper returning ErrStorageNotFound propagates as error
  • MaxActorChainDepth: second token exchange on already-delegated token rejected
  • Audience whitelist: unknown audience parameter rejected with invalid_target

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Benchmark regression report

Threshold: 25%

goos: linux
goarch: amd64
pkg: github.com/glincker/theauth-go
cpu: AMD EPYC 7763 64-Core Processor                
                     │ base-bench.txt │         pr-bench.txt          │
                     │     sec/op     │   sec/op     vs base          │
RateLimitReadHeavy-4      79.27n ± 1%   78.38n ± 1%  ~ (p=0.093 n=10)

                     │ base-bench.txt │          pr-bench.txt          │
                     │      B/op      │    B/op     vs base            │
RateLimitReadHeavy-4       0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

                     │ base-bench.txt │          pr-bench.txt          │
                     │   allocs/op    │ allocs/op   vs base            │
RateLimitReadHeavy-4       0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=10) ¹
¹ all samples are equal

pkg: github.com/glincker/theauth-go/crypto
               │ base-bench.txt │            pr-bench.txt            │
               │     sec/op     │   sec/op     vs base               │
Argon2Hash-4        44.72m ± 2%   44.60m ± 3%       ~ (p=0.315 n=10)
Argon2Verify-4      44.77m ± 1%   44.79m ± 1%       ~ (p=0.579 n=10)
geomean             44.74m        44.70m       -0.11%

               │ base-bench.txt │            pr-bench.txt             │
               │      B/op      │     B/op      vs base               │
Argon2Hash-4       64.01Mi ± 0%   64.01Mi ± 0%  -0.00% (p=0.023 n=10)
Argon2Verify-4     64.01Mi ± 0%   64.01Mi ± 0%       ~ (p=0.564 n=10)
geomean            64.01Mi        64.01Mi       -0.00%

               │ base-bench.txt │           pr-bench.txt            │
               │   allocs/op    │ allocs/op   vs base               │
Argon2Hash-4         102.0 ± 0%   102.0 ± 1%       ~ (p=0.628 n=10)
Argon2Verify-4       103.5 ± 0%   103.0 ± 1%       ~ (p=0.650 n=10)
geomean              102.7        102.5       -0.24%

pkg: github.com/glincker/theauth-go/internal/bench
                               │ base-bench.txt │            pr-bench.txt            │
                               │     sec/op     │   sec/op     vs base               │
AuditRedactor-4                     1.364µ ± 2%   1.345µ ± 0%  -1.36% (p=0.000 n=10)
OAuthCallback-4                    1008.0n ± 1%   991.8n ± 1%  -1.61% (p=0.000 n=10)
OAuthCodeFlow-4                     48.43µ ± 1%   47.88µ ± 1%  -1.15% (p=0.023 n=10)
OAuthTokenEndpointRefreshHit-4      49.72µ ± 0%   48.93µ ± 1%  -1.59% (p=0.000 n=10)
JWKSEndpoint-4                      6.812µ ± 2%   6.714µ ± 1%  -1.44% (p=0.001 n=10)
SCIMTokenAuth-4                     17.48µ ± 6%   18.45µ ± 1%  +5.55% (p=0.005 n=10)
SessionLookup-4                     8.959µ ± 2%   9.277µ ± 3%  +3.55% (p=0.000 n=10)
geomean                             8.618µ        8.639µ       +0.24%

                               │ base-bench.txt │             pr-bench.txt              │
                               │      B/op      │     B/op      vs base                 │
AuditRedactor-4                      672.0 ± 0%     672.0 ± 0%       ~ (p=1.000 n=10) ¹
OAuthCallback-4                    1.344Ki ± 0%   1.344Ki ± 0%       ~ (p=1.000 n=10) ¹
OAuthCodeFlow-4                    15.74Ki ± 0%   15.78Ki ± 0%  +0.29% (p=0.000 n=10)
OAuthTokenEndpointRefreshHit-4     15.50Ki ± 0%   15.54Ki ± 0%  +0.28% (p=0.000 n=10)
JWKSEndpoint-4                     2.963Ki ± 0%   2.963Ki ± 0%       ~ (p=1.000 n=10) ¹
SCIMTokenAuth-4                    9.765Ki ± 0%   9.765Ki ± 0%       ~ (p=0.120 n=10)
SessionLookup-4                    8.451Ki ± 0%   8.451Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean                            4.725Ki        4.729Ki       +0.08%
¹ all samples are equal

                               │ base-bench.txt │             pr-bench.txt             │
                               │   allocs/op    │  allocs/op   vs base                 │
AuditRedactor-4                      4.000 ± 0%    4.000 ± 0%       ~ (p=1.000 n=10) ¹
OAuthCallback-4                      5.000 ± 0%    5.000 ± 0%       ~ (p=1.000 n=10) ¹
OAuthCodeFlow-4                      99.00 ± 0%   100.00 ± 0%  +1.01% (p=0.000 n=10)
OAuthTokenEndpointRefreshHit-4       99.00 ± 0%   100.00 ± 0%  +1.01% (p=0.000 n=10)
JWKSEndpoint-4                       37.00 ± 0%    37.00 ± 0%       ~ (p=1.000 n=10) ¹
SCIMTokenAuth-4                      56.00 ± 0%    56.00 ± 0%       ~ (p=1.000 n=10) ¹
SessionLookup-4                      42.00 ± 0%    42.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean                              28.95         29.04       +0.29%
¹ all samples are equal

pkg: github.com/glincker/theauth-go/internal/jwt
            │ base-bench.txt │            pr-bench.txt            │
            │     sec/op     │   sec/op     vs base               │
JWTSign-4        31.08µ ± 0%   30.77µ ± 0%  -0.99% (p=0.000 n=10)
JWTVerify-4      71.92µ ± 1%   71.69µ ± 1%       ~ (p=0.631 n=10)
geomean          47.28µ        46.97µ       -0.66%

            │ base-bench.txt │             pr-bench.txt              │
            │      B/op      │     B/op      vs base                 │
JWTSign-4       2.782Ki ± 0%   2.782Ki ± 0%       ~ (p=1.000 n=10) ¹
JWTVerify-4     3.422Ki ± 0%   3.422Ki ± 0%       ~ (p=1.000 n=10) ¹
geomean         3.086Ki        3.086Ki       +0.00%
¹ all samples are equal

            │ base-bench.txt │            pr-bench.txt             │
            │   allocs/op    │ allocs/op   vs base                 │
JWTSign-4         15.00 ± 0%   15.00 ± 0%       ~ (p=1.000 n=10) ¹
JWTVerify-4       61.00 ± 0%   61.00 ± 0%       ~ (p=1.000 n=10) ¹
geomean           30.25        30.25       +0.00%
¹ all samples are equal

Generated by the bench workflow. A + delta means the PR branch is slower.

thegdsks added 3 commits June 22, 2026 15:25
…ge polish

Adds RFC 7523 section 2.2 (private_key_jwt client authentication via
signed JWT assertion) and section 2.1 (jwt-bearer grant type for
externally-issued JWTs from GCP SA, k8s OIDC, AWS IAM Roles Anywhere).
Token-exchange gains MaxActorChainDepth config knob and audience
whitelist validation. 10 new tests; all pass -race -count=3.

New public surface:
- JWTBearerConfig, TrustedJWTIssuer, SubjectMapper interface
- SubMapper (sub=ULID passthrough), EmailMapper (email claim lookup)
- JWTBearerStorage interface + memory.Store implementation (InsertJTI,
  SweepExpiredJTIs) with in-process sync.Map fallback
- JWTBearerGrant forwarder on *TheAuth
- AS metadata: private_key_jwt, client_secret_jwt,
  token_endpoint_auth_signing_alg_values_supported,
  urn:ietf:params:oauth:grant-type:jwt-bearer in grant_types_supported
@thegdsks
thegdsks force-pushed the feat/jwt-bearer-grant-2026-06-22 branch from 4a766cd to 2cf1c72 Compare June 22, 2026 20:34
@thegdsks
thegdsks merged commit 14f845c into main Jun 22, 2026
4 checks passed
@thegdsks
thegdsks deleted the feat/jwt-bearer-grant-2026-06-22 branch June 22, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant