Skip to content

Add OAuth 2.0 endpoints#686

Open
rickyrombo wants to merge 5 commits intomjp-oauth-dbfrom
mjp-oauth-endpoints
Open

Add OAuth 2.0 endpoints#686
rickyrombo wants to merge 5 commits intomjp-oauth-dbfrom
mjp-oauth-endpoints

Conversation

@rickyrombo
Copy link
Contributor

  • 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

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/me endpoints and supporting helpers (PKCE + refresh rotation + revocation).
  • Extends auth middleware and request signer extraction to accept Authorization: Bearer <opaque_access_token> backed by oauth_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.

Comment on lines +196 to +204
// 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)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +163
// 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")
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +414 to +423
// 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{})
}

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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{})
}

Copilot uses AI. Check for mistakes.
Comment on lines +256 to 265
// 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))
}
}
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +161
// --- /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",
})
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// Check cache
if app.oauthTokenCache != nil {
if entry, ok := app.oauthTokenCache.Get(token); ok {
return entry, true
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Suggested change
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.

Copilot uses AI. Check for mistakes.
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.

2 participants