docs(jwt): add JWT verification guide for resource servers#118
docs(jwt): add JWT verification guide for resource servers#118
Conversation
- Add comprehensive JWT verification guide with JWKS workflow, code examples (Go, Python, Node.js), caching best practices, and key rotation guidance - Add condensed web UI version accessible at /docs/jwt-verification - Add JWT Verification link to navbar docs dropdown, README, and all existing doc pages - Add prism-javascript syntax highlighting for docs pages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds end-user and developer documentation for verifying AuthGate-issued JWTs locally via JWKS (RS256/ES256), and wires the new guide into the web docs UI and repository docs.
Changes:
- Adds a new JWT verification guide in
/docsand a web-rendered version under/docs/jwt-verification. - Updates docs navigation (navbar + docs sidebar ordering) and cross-links “Related” sections across existing docs pages.
- Enables Prism JavaScript syntax highlighting on docs pages.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/templates/navbar_component.templ | Adds “JWT Verification” entry to the Docs dropdown (logged-in and logged-out). |
| internal/templates/docs_page.templ | Loads Prism JavaScript grammar for highlighting JS code blocks. |
| internal/templates/docs/jwt-verification.md | Adds new web docs page content for JWT verification via JWKS. |
| internal/templates/docs/getting-started.md | Adds “JWT Verification” to Related links. |
| internal/templates/docs/device-flow.md | Adds “JWT Verification” to Related links. |
| internal/templates/docs/client-credentials.md | Adds “JWT Verification” to Related links. |
| internal/templates/docs/auth-code-flow.md | Adds “JWT Verification” to Related links. |
| internal/handlers/docs.go | Registers jwt-verification slug/title in docs sidebar ordering and routing map. |
| docs/JWT_VERIFICATION.md | Adds comprehensive repository-level JWT verification guide. |
| docs/CONFIGURATION.md | Links to the new JWT verification guide from JWT signing/JWKS config section. |
| README.md | Adds JWT verification guide to Advanced Topics list. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "scope": "openid profile email", | ||
| "type": "access_token", | ||
| "exp": 1700000000, | ||
| "iat": 1699996400, | ||
| "iss": "https://your-authgate", | ||
| "sub": "user-uuid", | ||
| "jti": "unique-token-id" | ||
| } | ||
| ``` | ||
|
|
||
| | Claim | Description | | ||
| | ----------- | -------------------------------------- | | ||
| | `user_id` | User identifier | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated granted scopes | | ||
| | `type` | `access_token` or `refresh_token` | | ||
| | `exp` | Expiration time (Unix timestamp) | |
There was a problem hiding this comment.
The example token payload/table uses type: "access_token" / "refresh_token", but AuthGate’s JWT type claim values are "access" and "refresh" (see internal/token/types.go). As written, this would cause resource servers to reject valid tokens if they implement the check literally.
| ```json | ||
| { | ||
| "user_id": "user-uuid", | ||
| "client_id": "client-uuid", | ||
| "scope": "openid profile email", | ||
| "type": "access_token", | ||
| "exp": 1700000000, | ||
| "iat": 1699996400, | ||
| "iss": "https://your-authgate", | ||
| "sub": "user-uuid", | ||
| "jti": "unique-token-id" | ||
| } | ||
| ``` | ||
|
|
||
| | Claim | Description | | ||
| | ----------- | -------------------------------------- | | ||
| | `user_id` | User identifier | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated list of granted scopes | | ||
| | `type` | `access_token` or `refresh_token` | | ||
| | `exp` | Expiration time (Unix timestamp) | |
There was a problem hiding this comment.
The example token payload/table uses type: "access_token" / "refresh_token", but AuthGate’s JWT type claim values are "access" and "refresh" (see internal/token/types.go). Readers implementing the doc as-is will incorrectly reject valid tokens.
| This guide explains how **resource servers** (your APIs and microservices) can verify AuthGate-issued JWT tokens locally using public keys, without calling back to AuthGate on every request. | ||
|
|
There was a problem hiding this comment.
The guide emphasizes “no callback to AuthGate” for verification, but AuthGate’s authoritative validation (/oauth/tokeninfo) also checks DB state (revoked/disabled/expired). Please add a brief warning that purely local verification won’t detect revocation until expiry, and point readers to /oauth/tokeninfo if they need real-time revocation enforcement.
| 5. **Validate standard claims**: | ||
| - `exp` — token is not expired | ||
| - `iss` — matches your expected AuthGate URL | ||
| - `type` — is `access_token` (not `refresh_token`) | ||
| 6. **Check authorization** — verify `scope` and `client_id` match your requirements |
There was a problem hiding this comment.
AuthGate access tokens use type: "access" (and refresh tokens "refresh"), but this section says to validate type is access_token. Please update to the actual claim values so resource servers don’t implement a failing check.
| // Check token type | ||
| if (payload.type !== "access_token") { | ||
| res.writeHead(401); | ||
| res.end(JSON.stringify({ error: "Not an access token" })); | ||
| return; | ||
| } |
There was a problem hiding this comment.
This Node.js example checks payload.type !== "access_token", but AuthGate access tokens use type: "access". Please update the example to match the real claim values to avoid misleading resource server implementations.
docs/JWT_VERIFICATION.md
Outdated
| return jsonify({"error": f"Invalid token: {e}"}), 401 | ||
|
|
||
| # Check token type | ||
| if payload.get("type") != "access_token": |
There was a problem hiding this comment.
This Python example checks payload.get("type") != "access_token", but AuthGate uses type: "access" for access tokens. Please update the check to the actual claim value so the example doesn’t reject valid tokens.
| if payload.get("type") != "access_token": | |
| if payload.get("type") != "access": |
|
|
||
| - **JWKS empty for HS256** — Switch to RS256 or ES256 for JWKS-based verification | ||
| - **Not validating `iss`** — Always check the issuer matches your AuthGate URL | ||
| - **Accepting refresh tokens** — Always verify `type` is `access_token` |
There was a problem hiding this comment.
This bullet says to verify type is access_token, but AuthGate access tokens use type: "access". Please update to the actual claim value so the pitfall guidance is correct.
| - **Accepting refresh tokens** — Always verify `type` is `access_token` | |
| - **Accepting refresh tokens** — Always verify `type` is `access` |
| Verify AuthGate-issued JWT tokens at your resource servers using public keys — no callback to AuthGate needed. | ||
|
|
There was a problem hiding this comment.
The intro says no callback to AuthGate is needed, but local verification cannot detect server-side revocation/disabled tokens because AuthGate also enforces token state via DB in /oauth/tokeninfo (TokenService.ValidateToken). Please add a short note about this tradeoff and when to use token introspection/tokeninfo instead of (or in addition to) purely local verification.
docs/JWT_VERIFICATION.md
Outdated
| } | ||
|
|
||
| // Check token type | ||
| if claims["type"] != "access_token" { |
There was a problem hiding this comment.
This Go example checks claims["type"] != "access_token", but AuthGate sets the JWT type claim to "access" for access tokens. Update the check (and any surrounding prose) to match the real claim values.
| if claims["type"] != "access_token" { | |
| if claims["type"] != "access" { |
| ```json | ||
| { | ||
| "issuer": "https://your-authgate", | ||
| "jwks_uri": "https://your-authgate/.well-known/jwks.json", | ||
| "id_token_signing_alg_values_supported": ["RS256"], | ||
| "token_endpoint": "https://your-authgate/oauth/token", | ||
| "authorization_endpoint": "https://your-authgate/oauth/authorize", | ||
| "userinfo_endpoint": "https://your-authgate/oauth/userinfo" |
There was a problem hiding this comment.
The OIDC discovery response example hard-codes id_token_signing_alg_values_supported to RS256, but AuthGate returns the configured algorithm (RS256 or ES256). Please make the example/config note reflect that this value changes with JWT_SIGNING_ALGORITHM (and that the field may be omitted if ID tokens aren’t supported).
- Fix type claim values: access_token → access, refresh_token → refresh - Add revocation tradeoff warning (local verification vs /oauth/tokeninfo) - Clarify HS256 JWKS behavior (endpoint exists but returns empty key set) - Note that id_token_signing_alg_values_supported varies with JWT_SIGNING_ALGORITHM Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ```json | ||
| { | ||
| "issuer": "https://your-authgate", | ||
| "jwks_uri": "https://your-authgate/.well-known/jwks.json", | ||
| "id_token_signing_alg_values_supported": ["RS256"], // Reflects JWT_SIGNING_ALGORITHM (RS256 or ES256) | ||
| "token_endpoint": "https://your-authgate/oauth/token", | ||
| "authorization_endpoint": "https://your-authgate/oauth/authorize", | ||
| "userinfo_endpoint": "https://your-authgate/oauth/userinfo" | ||
| } |
There was a problem hiding this comment.
This fenced block is labeled as json but contains a // ... comment, which is not valid JSON. Either remove the comment (and explain it in surrounding prose) or change the code fence to a format that permits comments (e.g., jsonc/javascript) to avoid confusing readers and breaking copy/paste.
docs/JWT_VERIFICATION.md
Outdated
|
|
||
| ## Key Rotation | ||
|
|
||
| AuthGate supports key rotation with zero-downtime: |
There was a problem hiding this comment.
“AuthGate supports key rotation with zero-downtime” is not guaranteed by the described procedure (it requires restarting AuthGate). Please reword to clarify that zero-downtime requires a rolling restart / multiple instances behind a load balancer, or drop the zero-downtime claim.
| AuthGate supports key rotation with zero-downtime: | |
| AuthGate supports key rotation. To achieve zero-downtime, run multiple AuthGate instances behind a load balancer and perform a rolling restart: |
docs/JWT_VERIFICATION.md
Outdated
| > For key management security practices, see the [Security Guide](SECURITY.md#asymmetric-key-management-rs256es256). | ||
|
|
There was a problem hiding this comment.
This link target appears to be broken: SECURITY.md#asymmetric-key-management-rs256es256 doesn’t correspond to a heading anchor in docs/SECURITY.md (the matching text isn’t a markdown heading). Please update the link to a valid anchor, or convert the referenced Security section into a heading so the anchor exists.
| claims := token.Claims.(jwt.MapClaims) | ||
| if claims["type"] != "access" { | ||
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | ||
| return | ||
| } | ||
|
|
||
| userID := claims["user_id"].(string) |
There was a problem hiding this comment.
The Go example uses unchecked type assertions (token.Claims.(jwt.MapClaims) and direct indexing) which can panic if claims aren’t in the expected shape. Since this is guidance for resource servers, it’d be safer to use the claims, ok := ... pattern and handle missing/non-string claim values with a 401 instead of crashing.
| claims := token.Claims.(jwt.MapClaims) | |
| if claims["type"] != "access" { | |
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | |
| return | |
| } | |
| userID := claims["user_id"].(string) | |
| claims, ok := token.Claims.(jwt.MapClaims) | |
| if !ok { | |
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | |
| return | |
| } | |
| tokenType, ok := claims["type"].(string) | |
| if !ok || tokenType != "access" { | |
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | |
| return | |
| } | |
| userID, ok := claims["user_id"].(string) | |
| if !ok { | |
| http.Error(w, "Missing or invalid user_id claim", http.StatusUnauthorized) | |
| return | |
| } |
| claims := token.Claims.(jwt.MapClaims) | ||
| if claims["type"] != "access" { | ||
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | ||
| return | ||
| } | ||
|
|
||
| userID := claims["user_id"].(string) |
There was a problem hiding this comment.
claims["user_id"].(string) will panic if user_id is missing or not a string (e.g., token from a different issuer). Consider using a safe type assertion and returning 401 on failure to keep the example robust.
| claims := token.Claims.(jwt.MapClaims) | |
| if claims["type"] != "access" { | |
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | |
| return | |
| } | |
| userID := claims["user_id"].(string) | |
| claims, ok := token.Claims.(jwt.MapClaims) | |
| if !ok { | |
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | |
| return | |
| } | |
| tokenType, ok := claims["type"].(string) | |
| if !ok || tokenType != "access" { | |
| http.Error(w, "Invalid token type", http.StatusUnauthorized) | |
| return | |
| } | |
| userID, ok := claims["user_id"].(string) | |
| if !ok { | |
| http.Error(w, "Invalid or missing user_id claim", http.StatusUnauthorized) | |
| return | |
| } |
docs/JWT_VERIFICATION.md
Outdated
| "context" | ||
| "fmt" | ||
| "log" | ||
| "net/http" | ||
| "strings" | ||
| "time" |
There was a problem hiding this comment.
The Go example won’t compile as written: context and time are imported but never used. Please remove unused imports or update the snippet to actually use them (e.g., context-aware JWKS refresh options) so copy/paste works.
| "context" | |
| "fmt" | |
| "log" | |
| "net/http" | |
| "strings" | |
| "time" | |
| "fmt" | |
| "log" | |
| "net/http" | |
| "strings" |
- Remove invalid JSON comment, move note to surrounding prose - Fix zero-downtime claim: clarify rolling restart requirement - Fix broken SECURITY.md anchor link - Add safe type assertions in Go examples to prevent panics - Remove unused imports (context, time) from Go example Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import ( | ||
| "fmt" | ||
| "log" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| "github.com/MicahParks/keyfunc/v3" | ||
| "github.com/golang-jwt/jwt/v5" |
There was a problem hiding this comment.
The Go example won’t compile as written: it imports context and time but neither is used in the snippet. Remove unused imports (or add code that uses them) so copy/paste works cleanly.
| // Parse and verify the JWT | ||
| token, err := jwt.Parse(tokenString, k.Keyfunc, | ||
| jwt.WithIssuer("https://your-authgate"), | ||
| jwt.WithExpirationRequired(), |
There was a problem hiding this comment.
The Go verification example should explicitly restrict accepted JWT algorithms (e.g., to RS256/ES256) when parsing. Relying on whatever alg appears in the header is a common footgun for copy/paste consumers; add a parser option that enforces an allowlist of expected algorithms.
| jwt.WithExpirationRequired(), | |
| jwt.WithExpirationRequired(), | |
| jwt.WithValidMethods([]string{"RS256", "ES256"}), |
docs/JWT_VERIFICATION.md
Outdated
| scopeStr, _ := claims["scope"].(string) | ||
| scopes := strings.Fields(scopeStr) | ||
| if !contains(scopes, "read") { | ||
| http.Error(w, "Insufficient scope", http.StatusForbidden) | ||
| return | ||
| } | ||
|
|
||
| userID, _ := claims["user_id"].(string) |
There was a problem hiding this comment.
This sample uses unchecked type assertions (claims["scope"].(string), claims["user_id"].(string)), which can panic on malformed tokens or unexpected claim shapes. Prefer checked assertions / claims["..."] extraction with ok handling so the example fails closed with 401/403 instead of crashing the server.
| scopeStr, _ := claims["scope"].(string) | |
| scopes := strings.Fields(scopeStr) | |
| if !contains(scopes, "read") { | |
| http.Error(w, "Insufficient scope", http.StatusForbidden) | |
| return | |
| } | |
| userID, _ := claims["user_id"].(string) | |
| scopeStr, ok := claims["scope"].(string) | |
| if !ok || scopeStr == "" { | |
| http.Error(w, "Insufficient scope", http.StatusForbidden) | |
| return | |
| } | |
| scopes := strings.Fields(scopeStr) | |
| if !contains(scopes, "read") { | |
| http.Error(w, "Insufficient scope", http.StatusForbidden) | |
| return | |
| } | |
| userID, ok := claims["user_id"].(string) | |
| if !ok || userID == "" { | |
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | |
| return | |
| } |
| | `user_id` | User identifier | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated granted scopes | | ||
| | `type` | `access` or `refresh` | | ||
| | `exp` | Expiration time (Unix timestamp) | | ||
| | `iss` | Issuer URL (AuthGate's BASE_URL) | | ||
| | `sub` | Subject (same as user_id) | | ||
| | `jti` | Unique token identifier (UUID) | | ||
|
|
There was a problem hiding this comment.
Access tokens for client_credentials use a synthetic subject ("client:") rather than a user UUID, so the payload/table shouldn’t imply user_id/sub are always a human user identifier. Please clarify this edge case to prevent incorrect downstream authorization decisions.
| | `user_id` | User identifier | | |
| | `client_id` | OAuth client that requested the token | | |
| | `scope` | Space-separated granted scopes | | |
| | `type` | `access` or `refresh` | | |
| | `exp` | Expiration time (Unix timestamp) | | |
| | `iss` | Issuer URL (AuthGate's BASE_URL) | | |
| | `sub` | Subject (same as user_id) | | |
| | `jti` | Unique token identifier (UUID) | | |
| | `user_id` | End-user identifier when the token represents a user; may be absent for `client_credentials` tokens | | |
| | `client_id` | OAuth client that requested the token | | |
| | `scope` | Space-separated granted scopes | | |
| | `type` | `access` or `refresh` | | |
| | `exp` | Expiration time (Unix timestamp) | | |
| | `iss` | Issuer URL (AuthGate's BASE_URL) | | |
| | `sub` | Subject identifier: user UUID for user tokens, or `client:<client_id>` for `client_credentials` tokens | | |
| | `jti` | Unique token identifier (UUID) | | |
| > **Note:** For access tokens issued via the `client_credentials` grant, there is no end user. In that case, `sub` will be a synthetic client subject (`client:<client_id>`), and `user_id` is omitted. |
| // Parse and verify the JWT using JWKS | ||
| token, err := jwt.Parse(tokenString, k.Keyfunc, | ||
| jwt.WithIssuer("https://your-authgate"), | ||
| jwt.WithExpirationRequired(), |
There was a problem hiding this comment.
The Go snippet should restrict accepted JWT algorithms (RS256/ES256) during parsing. Without an explicit allowlist, copy/paste consumers may inadvertently accept unexpected alg values depending on library defaults and key selection behavior.
| jwt.WithExpirationRequired(), | |
| jwt.WithExpirationRequired(), | |
| jwt.WithValidMethods([]string{"RS256", "ES256"}), |
docs/JWT_VERIFICATION.md
Outdated
| ```json | ||
| { | ||
| "user_id": "user-uuid", | ||
| "client_id": "client-uuid", | ||
| "scope": "openid profile email", | ||
| "type": "access", | ||
| "exp": 1700000000, | ||
| "iat": 1699996400, | ||
| "iss": "https://your-authgate", | ||
| "sub": "user-uuid", | ||
| "jti": "unique-token-id" | ||
| } | ||
| ``` | ||
|
|
||
| | Claim | Description | | ||
| | ----------- | -------------------------------------- | | ||
| | `user_id` | User identifier | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated list of granted scopes | | ||
| | `type` | `access` or `refresh` | | ||
| | `exp` | Expiration time (Unix timestamp) | | ||
| | `iat` | Issued-at time (Unix timestamp) | | ||
| | `iss` | Issuer URL (AuthGate's `BASE_URL`) | | ||
| | `sub` | Subject (same as `user_id`) | |
There was a problem hiding this comment.
JWT access tokens issued by AuthGate set user_id/sub to the token’s subject. For client_credentials, the subject is a synthetic identity like client:<clientID> (see LocalTokenProvider comment). The example payload/table currently implies sub/user_id are always a user UUID; please clarify this to avoid resource servers incorrectly assuming a human user is always present.
| try { | ||
| // Verify the JWT using JWKS (auto-fetched and cached) | ||
| const { payload } = await jwtVerify(token, JWKS, { | ||
| issuer: AUTHGATE_URL, |
There was a problem hiding this comment.
In the jose example, jwtVerify should be configured with an explicit algorithm allowlist (e.g., RS256/ES256) rather than accepting whatever alg is present in the JWT header. This reduces the risk of alg-confusion issues for developers copying the snippet.
| issuer: AUTHGATE_URL, | |
| issuer: AUTHGATE_URL, | |
| algorithms: ["RS256", "ES256"], |
docs/JWT_VERIFICATION.md
Outdated
| AuthGate supports key rotation. To achieve zero-downtime, run multiple AuthGate instances behind a load balancer and perform a rolling restart: | ||
|
|
||
| 1. **Generate a new key pair** (see [Configuring AuthGate](#configuring-authgate)) | ||
| 2. **Update `JWT_PRIVATE_KEY_PATH`** (and optionally `JWT_KEY_ID`) in AuthGate's configuration | ||
| 3. **Restart AuthGate** — new tokens are signed with the new key; the JWKS endpoint serves the new public key | ||
| 4. **Resource servers adapt automatically** — tokens with an unknown `kid` trigger a JWKS re-fetch | ||
|
|
||
| ### Timeline | ||
|
|
||
| ``` | ||
| T+0: AuthGate restarts with new key | ||
| T+0: New tokens signed with new kid | ||
| T+0: JWKS endpoint serves new public key | ||
| T+0~1h: Resource servers with cached old JWKS re-fetch on unknown kid | ||
| T+1h: All old access tokens have expired (default expiry = 1 hour) | ||
| ``` | ||
|
|
||
| ### Limitations | ||
|
|
||
| - AuthGate serves **a single active public key** in the JWKS response (multi-key JWKS is not currently supported) | ||
| - During rotation, resource servers that don't handle unknown `kid` gracefully may reject new tokens until their JWKS cache expires | ||
|
|
There was a problem hiding this comment.
The Key Rotation section claims “zero-downtime”, but AuthGate’s JWKS handler is built at startup and serves only the single current public key. After rotation, any still-unexpired tokens signed with the previous key will fail verification once a resource server refreshes its JWKS cache (periodic refresh, restart, etc.). Please adjust the wording/steps to reflect this limitation and provide an operationally safe rotation strategy (e.g., rotate only after old access tokens have expired, or note that multi-key JWKS is required for true overlap).
| if (!auth.startsWith("Bearer ")) { | ||
| res.writeHead(401); | ||
| res.end(JSON.stringify({ error: "Missing Bearer token" })); | ||
| return; | ||
| } |
There was a problem hiding this comment.
The Node jose example should set an explicit algorithm allowlist in jwtVerify options (e.g., RS256/ES256). This prevents consumers from accidentally accepting tokens signed with unexpected algorithms if they copy the snippet.
| 1. Generate a new key pair and update `JWT_PRIVATE_KEY_PATH` in AuthGate | ||
| 2. Restart AuthGate — new tokens are signed with the new key | ||
| 3. Resource servers detect the unknown `kid` and re-fetch JWKS automatically | ||
|
|
||
| > AuthGate currently serves a single active key. During rotation, allow up to 1 hour for cached JWKS to expire at resource servers. | ||
|
|
There was a problem hiding this comment.
This rotation guidance implies seamless behavior, but AuthGate serves only one active key in JWKS. Once a resource server refreshes JWKS after rotation, it will no longer be able to verify still-unexpired tokens signed by the old key. Please clarify the limitation and recommend a safe rotation procedure (rotate after old access tokens expire, or note that multi-key JWKS is needed for overlap).
| 1. Generate a new key pair and update `JWT_PRIVATE_KEY_PATH` in AuthGate | |
| 2. Restart AuthGate — new tokens are signed with the new key | |
| 3. Resource servers detect the unknown `kid` and re-fetch JWKS automatically | |
| > AuthGate currently serves a single active key. During rotation, allow up to 1 hour for cached JWKS to expire at resource servers. | |
| > **Important limitation**: AuthGate's JWKS endpoint exposes **only one active key at a time**. Once you rotate keys and a resource server refreshes JWKS, it will **no longer be able to verify still-unexpired tokens** that were signed with the old key. | |
| > | |
| > Seamless, overlapping rotation (where both old and new keys are accepted during a transition period) would require AuthGate to serve **multiple keys in JWKS simultaneously**, which is not currently supported. | |
| ### Safe rotation procedure (single-key JWKS) | |
| To avoid breaking verification for valid tokens: | |
| 1. Determine your **maximum access token lifetime** (e.g., 15 minutes, 1 hour). | |
| 2. Choose a rotation time and ensure that, **for at least one full token lifetime before rotation**, no new tokens are issued with the old key (for example, by scheduling rotation after a planned downtime or after disabling issuance to affected clients). | |
| 3. After you are confident that all access tokens signed with the old key have expired, generate a new key pair and update `JWT_PRIVATE_KEY_PATH` in AuthGate. | |
| 4. Restart AuthGate — new tokens are now signed with the new key, and the new public key is served via JWKS. | |
| 5. Resource servers will continue to verify existing tokens with the current JWKS; when they next refresh JWKS, **all still-active tokens should already be signed with the new key**. | |
| > If you rotate the key **before** all old access tokens have expired, any resource server that refreshes JWKS will start rejecting those old-but-unexpired tokens. Plan rotations accordingly, or wait for multi-key JWKS support if you require fully seamless overlap. |
- Add WithValidMethods to restrict accepted JWT algorithms in Go example - Add algorithms allowlist to Node.js jose example - Strengthen all Go type assertions with ok checks and error handling - Clarify client_credentials tokens: sub uses synthetic client subject, user_id may be absent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| | Claim | Description | | ||
| | ----------- | -------------------------------------- | | ||
| | `user_id` | End-user identifier; may be absent for `client_credentials` tokens | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated granted scopes | | ||
| | `type` | `access` or `refresh` | | ||
| | `exp` | Expiration time (Unix timestamp) | | ||
| | `iss` | Issuer URL (AuthGate's BASE_URL) | | ||
| | `sub` | Subject: user UUID for user tokens, or `client:<client_id>` for `client_credentials` tokens | | ||
| | `jti` | Unique token identifier (UUID) | |
There was a problem hiding this comment.
The claims table says user_id “may be absent for client_credentials tokens”, but AuthGate access token JWTs always include user_id (for client_credentials it is set to the synthetic machine identity client:<client_id>). Please update the claim description (and any surrounding text) to match actual token claims so resource servers don’t implement incorrect parsing logic.
| userID, ok := claims["user_id"].(string) | ||
| if !ok || userID == "" { | ||
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | ||
| return | ||
| } | ||
| fmt.Fprintf(w, "Hello, user %s!", userID) |
There was a problem hiding this comment.
This Go example rejects tokens when user_id is missing/empty and treats it as an end-user identifier. In AuthGate, client_credentials access tokens use user_id/sub like client:<client_id> (no end user). Consider adjusting the example to handle both cases (user token vs client token), e.g., use sub for a generic subject and/or branch on the client: prefix instead of requiring an end-user ID.
| userID, ok := claims["user_id"].(string) | |
| if !ok || userID == "" { | |
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | |
| return | |
| } | |
| fmt.Fprintf(w, "Hello, user %s!", userID) | |
| // Use "sub" as the generic subject. AuthGate may encode client credentials as "client:<client_id>". | |
| subject, _ := claims["sub"].(string) | |
| if subject == "" { | |
| // Fallback to "user_id" for older tokens | |
| subject, _ = claims["user_id"].(string) | |
| } | |
| if subject == "" { | |
| http.Error(w, "Invalid token claims: missing subject", http.StatusUnauthorized) | |
| return | |
| } | |
| if strings.HasPrefix(subject, "client:") { | |
| clientID := strings.TrimPrefix(subject, "client:") | |
| if clientID == "" { | |
| http.Error(w, "Invalid token claims: malformed client subject", http.StatusUnauthorized) | |
| return | |
| } | |
| fmt.Fprintf(w, "Hello, client %s!", clientID) | |
| return | |
| } | |
| fmt.Fprintf(w, "Hello, user %s!", subject) |
| } | ||
| ``` | ||
|
|
||
| > The `jwks_uri` field is only present when RS256 or ES256 is configured. The `id_token_signing_alg_values_supported` value reflects the configured `JWT_SIGNING_ALGORITHM` (e.g., `["ES256"]` when using ES256). |
There was a problem hiding this comment.
The note implies id_token_signing_alg_values_supported is always present when RS256/ES256 is configured, but the discovery handler omits this field when ID tokens aren’t supported. Please mention it may be absent (similar to docs/JWT_VERIFICATION.md) so readers don’t rely on it unconditionally.
| > The `jwks_uri` field is only present when RS256 or ES256 is configured. The `id_token_signing_alg_values_supported` value reflects the configured `JWT_SIGNING_ALGORITHM` (e.g., `["ES256"]` when using ES256). | |
| > The `jwks_uri` field is only present when RS256 or ES256 is configured. When present, the `id_token_signing_alg_values_supported` value reflects the configured `JWT_SIGNING_ALGORITHM` (e.g., `["ES256"]` when using ES256), but this field may be omitted entirely when ID tokens are not supported, so clients should not rely on it unconditionally. |
| | Claim | Description | | ||
| | ----------- | -------------------------------------- | | ||
| | `user_id` | End-user identifier; may be absent for `client_credentials` tokens | | ||
| | `client_id` | OAuth client that requested the token | | ||
| | `scope` | Space-separated list of granted scopes | | ||
| | `type` | `access` or `refresh` | | ||
| | `exp` | Expiration time (Unix timestamp) | | ||
| | `iat` | Issued-at time (Unix timestamp) | | ||
| | `iss` | Issuer URL (AuthGate's `BASE_URL`) | | ||
| | `sub` | Subject: user UUID for user tokens, or `client:<client_id>` for `client_credentials` tokens | | ||
| | `jti` | Unique token identifier (UUID) | | ||
|
|
||
| > **Note:** For access tokens issued via the `client_credentials` grant, there is no end user. In that case, `sub` is a synthetic client subject (`client:<client_id>`), and `user_id` may be omitted. | ||
|
|
There was a problem hiding this comment.
The claims table states user_id may be absent for client_credentials tokens, but AuthGate access token JWTs always include user_id; for client_credentials it is set to the synthetic machine identity client:<client_id>. Please update this row (and the note below it) to reflect actual behavior so resource servers parse subjects correctly.
docs/JWT_VERIFICATION.md
Outdated
| userID, ok := claims["user_id"].(string) | ||
| if !ok || userID == "" { | ||
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | ||
| return | ||
| } | ||
| fmt.Fprintf(w, "Hello, user %s!", userID) |
There was a problem hiding this comment.
The examples assume user_id represents an end user (and error if it’s missing). For client_credentials, AuthGate uses a machine identity (client:<client_id>) in user_id/sub, so example resource servers should either treat sub as the subject generically or branch on the client: prefix rather than requiring an end-user. Updating the example avoids confusing M2M consumers.
| userID, ok := claims["user_id"].(string) | |
| if !ok || userID == "" { | |
| http.Error(w, "Invalid token claims", http.StatusUnauthorized) | |
| return | |
| } | |
| fmt.Fprintf(w, "Hello, user %s!", userID) | |
| // Determine the subject of the token (user or client) | |
| var subject string | |
| if sub, ok := claims["sub"].(string); ok && sub != "" { | |
| subject = sub | |
| } else if userID, ok := claims["user_id"].(string); ok && userID != "" { | |
| subject = userID | |
| } | |
| if subject == "" { | |
| http.Error(w, "Invalid token claims: missing subject", http.StatusUnauthorized) | |
| return | |
| } | |
| // Example: differentiate between user and client subjects | |
| if strings.HasPrefix(subject, "client:") { | |
| fmt.Fprintf(w, "Hello, client %s!", strings.TrimPrefix(subject, "client:")) | |
| } else { | |
| fmt.Fprintf(w, "Hello, user %s!", subject) | |
| } |
- Fix client_credentials claim docs: user_id is synthetic "client:<id>", not absent - Use sub claim in Go example for universal user/client handling - Clarify id_token_signing_alg_values_supported may be omitted when ID tokens unsupported Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
docs/JWT_VERIFICATION.md
Outdated
| - AuthGate serves **a single active public key** in the JWKS response (multi-key JWKS is not currently supported) | ||
| - During rotation, resource servers that don't handle unknown `kid` gracefully may reject new tokens until their JWKS cache expires | ||
|
|
||
| > For key management security practices, see the [Security Guide](SECURITY.md#asymmetric-key-management-rs256es256). |
There was a problem hiding this comment.
The link to SECURITY.md#asymmetric-key-management-rs256es256 appears to be a broken anchor. In docs/SECURITY.md the “Asymmetric Key Management (RS256/ES256)” text is not a markdown heading, so it won’t generate that fragment identifier. Consider either changing this link to an existing heading (e.g. SECURITY.md#secrets-management) or converting the target section in SECURITY.md into a proper heading so the anchor exists.
| > For key management security practices, see the [Security Guide](SECURITY.md#asymmetric-key-management-rs256es256). | |
| > For key management security practices, see the [Security Guide](SECURITY.md#secrets-management). |
…ction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| JWKS_URL = f"{AUTHGATE_URL}/.well-known/jwks.json" | ||
|
|
||
| # PyJWKClient caches JWKS keys automatically | ||
| jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600) |
There was a problem hiding this comment.
The Python example uses PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600), but cache_jwk_set is not a supported constructor argument in common PyJWT versions (the caching flag is typically cache_keys). As written, this snippet is likely to raise a TypeError for readers; update the example to use the correct PyJWT constructor parameters (or omit the unsupported flag and rely on the default caching behavior).
| jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600) | |
| jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600) |
docs/JWT_VERIFICATION.md
Outdated
| JWKS_URL = f"{AUTHGATE_URL}/.well-known/jwks.json" | ||
|
|
||
| # PyJWKClient caches JWKS keys automatically | ||
| jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600) |
There was a problem hiding this comment.
The Python example uses PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600), but cache_jwk_set is not a supported constructor argument in common PyJWT versions (the caching flag is typically cache_keys). As written, this snippet is likely to raise a TypeError for readers; update the example to use the correct PyJWT constructor parameters (or omit the unsupported flag and rely on the default caching behavior).
| jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600) | |
| jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600) |
| @NavDropdown("Docs", props.ActiveLink == "docs-getting-started" || props.ActiveLink == "docs-device-flow" || props.ActiveLink == "docs-auth-code-flow" || props.ActiveLink == "docs-client-credentials" || props.ActiveLink == "docs-jwt-verification") { | ||
| @NavDropdownItem("/docs/getting-started", "Getting Started", props.ActiveLink == "docs-getting-started", false, false) | ||
| @NavDropdownItem("/docs/device-flow", "Device Flow", props.ActiveLink == "docs-device-flow", false, false) | ||
| @NavDropdownItem("/docs/auth-code-flow", "Auth Code Flow", props.ActiveLink == "docs-auth-code-flow", false, false) | ||
| @NavDropdownItem("/docs/client-credentials", "Client Credentials", props.ActiveLink == "docs-client-credentials", false, false) | ||
| @NavDropdownItem("/docs/jwt-verification", "JWT Verification", props.ActiveLink == "docs-jwt-verification", false, false) |
There was a problem hiding this comment.
The docs dropdown active-state check is an ever-growing hardcoded OR list and is duplicated for logged-in vs logged-out nav. Since docs pages already set ActiveLink to "docs-"+slug, this can be simplified to a single prefix check (e.g., any ActiveLink that starts with docs-), reducing the chance of future docs pages not activating the dropdown.
| @NavDropdown("Docs", props.ActiveLink == "docs-getting-started" || props.ActiveLink == "docs-device-flow" || props.ActiveLink == "docs-auth-code-flow" || props.ActiveLink == "docs-client-credentials" || props.ActiveLink == "docs-jwt-verification") { | ||
| @NavDropdownItem("/docs/getting-started", "Getting Started", props.ActiveLink == "docs-getting-started", false, false) | ||
| @NavDropdownItem("/docs/device-flow", "Device Flow", props.ActiveLink == "docs-device-flow", false, false) | ||
| @NavDropdownItem("/docs/auth-code-flow", "Auth Code Flow", props.ActiveLink == "docs-auth-code-flow", false, false) | ||
| @NavDropdownItem("/docs/client-credentials", "Client Credentials", props.ActiveLink == "docs-client-credentials", false, false) | ||
| @NavDropdownItem("/docs/jwt-verification", "JWT Verification", props.ActiveLink == "docs-jwt-verification", false, false) |
There was a problem hiding this comment.
Same as above: the logged-out navbar duplicates a hardcoded OR list for docs active-state. Consider switching to a simple docs- prefix check (consistent with how docs pages set ActiveLink) to avoid needing to update this list for every new docs page.
- Fix Python PyJWKClient param: cache_jwk_set → cache_keys - Simplify navbar docs dropdown active check with IsDocsActive() helper to avoid hardcoded OR list that must be updated for each new docs page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/templates/props.go
Outdated
| @@ -22,6 +22,11 @@ type NavbarProps struct { | |||
| PendingClientsCount int // Badge count for admin → OAuth Clients link | |||
| } | |||
|
|
|||
| // IsDocsActive returns true if the current ActiveLink belongs to a docs page. | |||
| func (p *NavbarProps) IsDocsActive() bool { | |||
| return len(p.ActiveLink) > 5 && p.ActiveLink[:5] == "docs-" | |||
There was a problem hiding this comment.
IsDocsActive hard-codes the prefix length (> 5 / [:5]) and duplicates prefix logic in a less readable way. Consider switching to strings.HasPrefix(p.ActiveLink, "docs-") (no magic number / slicing), and update the ActiveLink field comment since it can also be docs-<slug> now (not just the non-docs values listed).
…comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| IsAdmin bool | ||
| ActiveLink string // "device", "sessions", "clients", "audit" | ||
| ActiveLink string // e.g. "device", "sessions", "clients", "audit", "docs-<slug>" | ||
| PendingClientsCount int // Badge count for admin → OAuth Clients link | ||
| } | ||
|
|
||
| // IsDocsActive returns true if the current ActiveLink belongs to a docs page. | ||
| func (p *NavbarProps) IsDocsActive() bool { | ||
| return strings.HasPrefix(p.ActiveLink, "docs-") |
There was a problem hiding this comment.
IsDocsActive uses a hard-coded length check/slice (len(...) > 5 and ActiveLink[:5] == "docs-"). This is a bit brittle and also makes the > 5 check inconsistent with the 5-char prefix. Consider switching to strings.HasPrefix(p.ActiveLink, "docs-") (and/or using len("docs-")) and updating the ActiveLink field comment above to reflect that it can contain docs-<slug> values as well.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
docs/JWT_VERIFICATION.md
Outdated
| AuthGate supports key rotation. To achieve zero-downtime, run multiple AuthGate instances behind a load balancer and perform a rolling restart: | ||
|
|
||
| 1. **Generate a new key pair** (see [Configuring AuthGate](#configuring-authgate)) | ||
| 2. **Update `JWT_PRIVATE_KEY_PATH`** (and optionally `JWT_KEY_ID`) in AuthGate's configuration | ||
| 3. **Restart AuthGate** — new tokens are signed with the new key; the JWKS endpoint serves the new public key |
There was a problem hiding this comment.
The Key Rotation section claims “To achieve zero-downtime” with a rolling restart behind a load balancer, but AuthGate’s JWKS currently serves only a single active key. During a rolling restart with mixed old/new instances, clients may receive tokens signed with one key while fetching JWKS from an instance serving the other key, causing intermittent verification failures. Please reword this to avoid promising zero-downtime, or clarify the extra coordination required (e.g., keep all instances on the same signing key until cutover, pre-warm resource-server JWKS caches, or implement multi-key JWKS support).
Remove zero-downtime claim; explain that all instances must switch simultaneously and resource servers should re-fetch JWKS on unknown kid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
Guide covers
Test plan
Generated with Claude Code