Conversation
rickyrombo
commented
Mar 4, 2026
- Adds support in the auth middleware for access tokens in the Authorization: Bearer header
- Adds a request helper to extract the signer from OAuth headers
- Adds a cache for that lookup
- Adds authorize, token, revoke and "me" endpoints
There was a problem hiding this comment.
Pull request overview
Adds first-class OAuth 2.0 (PKCE-style) support to the API server, including new OAuth endpoints, Bearer-token handling in auth middleware, and token-to-signer resolution with caching.
Changes:
- Introduces
/v1/oauth/authorize,/v1/oauth/token,/v1/oauth/revoke, and/v1/oauth/meendpoints and supporting helpers (PKCE + refresh rotation + revocation). - Extends auth middleware and request signer extraction to accept
Authorization: Bearer <opaque_access_token>backed byoauth_tokens. - Adds an in-memory cache for OAuth access-token lookups and seeds/test fixtures for new OAuth tables, plus a comprehensive OAuth test suite.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| database/seed.go | Adds base seed rows for new OAuth tables used in tests/fixtures. |
| api/v1_oauth.go | Implements OAuth endpoints, PKCE validation, token issuance/refresh/revocation, and access-token cache helpers. |
| api/v1_oauth_test.go | Adds tests for token/revoke/me flows, cache behavior, JWT iat validation, and middleware integration. |
| api/server.go | Wires OAuth routes and initializes an otter cache for OAuth token lookups. |
| api/request_helpers.go | Adds signer resolution from OAuth access tokens (client_id → api_secret). |
| api/auth_middleware.go | Adds PKCE Bearer token fallback in auth middleware to set authed wallet/user from oauth_tokens. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Atomically consume the code | ||
| var storedClientID, storedRedirectURI, storedCodeChallenge, storedCodeChallengeMethod, storedScope string | ||
| var storedUserID int32 | ||
| err := app.writePool.QueryRow(c.Context(), ` | ||
| UPDATE oauth_authorization_codes | ||
| SET used = true | ||
| WHERE code = $1 AND used = false AND expires_at > NOW() | ||
| RETURNING client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope | ||
| `, body.Code).Scan(&storedClientID, &storedUserID, &storedRedirectURI, &storedCodeChallenge, &storedCodeChallengeMethod, &storedScope) |
There was a problem hiding this comment.
The authorization code is marked used=true before verifying client_id and redirect_uri. If a code leaks, a caller can consume it with a mismatched client_id/redirect_uri and permanently invalidate the real client's exchange (PKCE stops token theft, but not this DoS). Consider including client_id and redirect_uri in the UPDATE ... WHERE clause (or doing a SELECT ... FOR UPDATE + verify + UPDATE in a transaction) so mismatches don't burn the code.
| // Atomically consume the code | |
| var storedClientID, storedRedirectURI, storedCodeChallenge, storedCodeChallengeMethod, storedScope string | |
| var storedUserID int32 | |
| err := app.writePool.QueryRow(c.Context(), ` | |
| UPDATE oauth_authorization_codes | |
| SET used = true | |
| WHERE code = $1 AND used = false AND expires_at > NOW() | |
| RETURNING client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope | |
| `, body.Code).Scan(&storedClientID, &storedUserID, &storedRedirectURI, &storedCodeChallenge, &storedCodeChallengeMethod, &storedScope) | |
| // Atomically consume the code, ensuring it belongs to this client and redirect URI | |
| var storedClientID, storedRedirectURI, storedCodeChallenge, storedCodeChallengeMethod, storedScope string | |
| var storedUserID int32 | |
| err := app.writePool.QueryRow(c.Context(), ` | |
| UPDATE oauth_authorization_codes | |
| SET used = true | |
| WHERE code = $1 | |
| AND client_id = $2 | |
| AND redirect_uri = $3 | |
| AND used = false | |
| AND expires_at > NOW() | |
| RETURNING client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope | |
| `, body.Code, clientID, body.RedirectURI).Scan(&storedClientID, &storedUserID, &storedRedirectURI, &storedCodeChallenge, &storedCodeChallengeMethod, &storedScope) |
| // Insert into oauth_authorization_codes | ||
| _, err = app.writePool.Exec(c.Context(), ` | ||
| INSERT INTO oauth_authorization_codes (code, client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope) | ||
| VALUES ($1, $2, $3, $4, $5, $6, $7) | ||
| `, code, clientID, int32(userId), body.RedirectURI, body.CodeChallenge, body.CodeChallengeMethod, body.Scope) | ||
| if err != nil { | ||
| app.logger.Error("Failed to insert auth code", zap.Error(err)) | ||
| return oauthError(c, fiber.StatusInternalServerError, "server_error", "Failed to create authorization code") | ||
| } |
There was a problem hiding this comment.
These handlers use app.writePool (e.g., for inserting auth codes) without guarding against writePool == nil. Other write endpoints in this codebase return a 500 with a "Database write not available"-style message when the write pool isn't configured. Consider adding the same guard at the top of the OAuth handlers to avoid nil-pointer panics on read-only deployments/configs.
| // Look up the token to get family_id for family-wide revocation | ||
| var familyID string | ||
| err := app.writePool.QueryRow(c.Context(), ` | ||
| SELECT family_id FROM oauth_tokens WHERE token = $1 | ||
| `, body.Token).Scan(&familyID) | ||
| if err != nil { | ||
| // Token not found or error — per RFC 7009, return 200 anyway | ||
| return c.JSON(fiber.Map{}) | ||
| } | ||
|
|
There was a problem hiding this comment.
oauthRevokeBody includes client_id, but v1OAuthRevoke never validates or uses it. This is easy to misread as enforcing client binding when it doesn't. Either remove the field from the request contract, or fetch client_id alongside family_id and ensure it matches the provided client_id (and decide what response to return on mismatch).
| // Look up the token to get family_id for family-wide revocation | |
| var familyID string | |
| err := app.writePool.QueryRow(c.Context(), ` | |
| SELECT family_id FROM oauth_tokens WHERE token = $1 | |
| `, body.Token).Scan(&familyID) | |
| if err != nil { | |
| // Token not found or error — per RFC 7009, return 200 anyway | |
| return c.JSON(fiber.Map{}) | |
| } | |
| // Look up the token to get family_id for family-wide revocation and validate client_id | |
| var ( | |
| familyID string | |
| dbClientID string | |
| ) | |
| err := app.writePool.QueryRow(c.Context(), ` | |
| SELECT family_id, client_id FROM oauth_tokens WHERE token = $1 | |
| `, body.Token).Scan(&familyID, &dbClientID) | |
| if err != nil { | |
| // Token not found or error — per RFC 7009, return 200 anyway | |
| return c.JSON(fiber.Map{}) | |
| } | |
| // If a client_id was provided, ensure it matches the token's client_id. | |
| // On mismatch, behave as if the token were unknown (no revocation, 200 response). | |
| if body.ClientID != "" && body.ClientID != dbClientID { | |
| return c.JSON(fiber.Map{}) | |
| } |
| // PKCE token fallback: resolve opaque Bearer token from oauth_tokens | ||
| if wallet == "" && bearerToken != "" { | ||
| if entry, ok := app.lookupOAuthAccessToken(c, bearerToken); ok { | ||
| wallet = strings.ToLower(entry.ClientID) | ||
| if myId == 0 { | ||
| myId = entry.UserID | ||
| c.Locals("myId", int(entry.UserID)) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
In the PKCE Bearer-token fallback, if myId is already set (via user_id query param), the code does not verify it matches the access token's user_id. That means a valid access token for user A could be used to authorize requests for user B as long as the app wallet has an approved grant for B, effectively turning per-user tokens into per-client tokens. Consider enforcing entry.UserID == myId when myId != 0 (similar to the OAuth JWT fallback), otherwise reject the request.
| // --- /oauth/token (authorization_code grant) --- | ||
|
|
||
| func TestOAuthTokenAuthorizationCode(t *testing.T) { | ||
| app := emptyTestApp(t) | ||
| clientID := seedOAuthTestData(t, app) | ||
| code, codeVerifier, _ := insertTestAuthCode(t, app, clientID, 100, "write") | ||
|
|
||
| status, body := oauthPostJSON(t, app, "/v1/oauth/token", map[string]string{ | ||
| "grant_type": "authorization_code", | ||
| "code": code, | ||
| "code_verifier": codeVerifier, | ||
| "client_id": clientID, | ||
| "redirect_uri": "https://example.com/callback", | ||
| }) | ||
|
|
||
| assert.Equal(t, 200, status) | ||
| assert.True(t, gjson.GetBytes(body, "access_token").Exists()) | ||
| assert.True(t, gjson.GetBytes(body, "refresh_token").Exists()) | ||
| jsonAssert(t, body, map[string]any{ | ||
| "token_type": "Bearer", | ||
| "expires_in": float64(3600), | ||
| "scope": "write", | ||
| }) | ||
| } |
There was a problem hiding this comment.
OAuth tests cover /oauth/token, /oauth/revoke, /oauth/me, cache behavior, and middleware, but there are no tests for /v1/oauth/authorize (happy path + required-field validation + redirect URI registration + write-scope grant enforcement). Adding those would help prevent regressions in the start of the PKCE flow.
| // Check cache | ||
| if app.oauthTokenCache != nil { | ||
| if entry, ok := app.oauthTokenCache.Get(token); ok { | ||
| return entry, true |
There was a problem hiding this comment.
oauthTokenCache.Get(token) returns a cached entry without re-checking expires_at (and the cached entry doesn't store it). This allows an access token to remain valid for up to the cache TTL after it has expired in the DB. Consider caching expires_at in the entry and validating it on cache hits (or set the cache TTL dynamically to min(defaultTTL, expires_at-now)).
| return entry, true | |
| // Re-validate that the token is still active in the database (not expired/revoked) | |
| var dummy int | |
| err := app.pool.QueryRow(c.Context(), ` | |
| SELECT 1 | |
| FROM oauth_tokens | |
| WHERE token = $1 AND token_type = 'access' AND is_revoked = false AND expires_at > NOW() | |
| `, token).Scan(&dummy) | |
| if err == nil { | |
| return entry, true | |
| } | |
| // If the token is no longer valid in the DB, fall through and perform a full lookup. |
…eived, normalize client_id to wallet address