From b6a5be1834132943788fd0c0175402f98d7995d2 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:24:19 -0700 Subject: [PATCH] fix(auth): populate oauthScope/myId in getSignerFromOAuthToken, clarify authMiddleware flow When a PKCE Bearer token resolves a signer via getSignerFromOAuthToken, populate oauthScope and myId directly on the context so that authMiddleware doesn't need a redundant second PKCE token lookup. Also restructures authMiddleware with explicit priority comments and moves signature headers to lowest priority after Bearer-based auth flows. Adds TestOAuthMe_WithApiSecret to verify /me works when the PKCE token belongs to a developer app that also has an api_secret in api_keys. Co-Authored-By: Claude Sonnet 4.6 --- api/auth_middleware.go | 72 +++++++++++++++++++++++++++++------------- api/request_helpers.go | 8 +++++ api/v1_oauth_test.go | 30 ++++++++++++++++++ 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 2fe90899..c86e6320 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -246,49 +246,77 @@ func (app *ApiServer) validateOAuthJWTTokenToWalletAndUserId(ctx context.Context // - the user is not authorized to act on behalf of "myId" // - the user is not authorized to act on behalf of "myWallet" func (app *ApiServer) authMiddleware(c *fiber.Ctx) error { + + // Try to populate the authorized wallet from the Authorization header or signature headers. The authMiddleware is designed to be flexible and support multiple auth methods, so it will attempt to resolve the authed wallet from multiple sources in the following order: + // 1a. Static dev app Bearer token or secret (e.g. api_access_key or AudiusApiSecret) - highest precedence since it's the most explicit + // 1b. OAuth 2.0 access token lookup - allows support for OAuth clients to do read/writes + // 2. OAuth JWT Bearer token - used by older clients with the implicit signed JWTs to do auth + // 3. OAuth 2.0 access token lookup - for cases where the dev app doesn't have their secret stored on API server - will only work for reads, not writes + // 4. Signature headers - legacy method used for reads var wallet string + // Start by trying to get the API key/secret from the Authorization header signer, _ := app.getApiSigner(c) myId := app.getMyId(c) if signer != nil { + app.logger.Debug("authMiddleware: resolved via app bearer/secret/oauth", zap.String("wallet", strings.ToLower(signer.Address))) wallet = strings.ToLower(signer.Address) } else { - wallet = app.recoverAuthorityFromSignatureHeaders(c) + // The api secret couldn't be found, try other methods: + // Extract Bearer token once for the fallback checks below var bearerToken string if authHeader := c.Get("Authorization"); authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { bearerToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) } - // OAuth JWT fallback: when Bearer token is not api_access_key, try as OAuth JWT (Plans app) - if wallet == "" && myId != 0 && bearerToken != "" { - if oauthWallet, jwtUserId, err := app.validateOAuthJWTTokenToWalletAndUserId(c.Context(), bearerToken); err == nil { - if int32(jwtUserId) == myId { - wallet = oauthWallet + if bearerToken != "" { + // OAuth JWT fallback: when Bearer token is not api_access_key, try as OAuth JWT (Plans app) + if wallet == "" && myId != 0 { + if oauthWallet, jwtUserId, err := app.validateOAuthJWTTokenToWalletAndUserId(c.Context(), bearerToken); err == nil { + if int32(jwtUserId) == myId { + app.logger.Debug("authMiddleware: resolved via OAuth JWT", zap.String("wallet", oauthWallet), zap.Int32("myId", myId)) + wallet = oauthWallet + } else { + app.logger.Warn("authMiddleware: OAuth JWT userId does not match myId", zap.Int32("jwtUserId", int32(jwtUserId)), zap.Int32("myId", myId)) + } } else { - app.logger.Warn("authMiddleware: OAuth JWT userId does not match myId", zap.Int32("jwtUserId", int32(jwtUserId)), zap.Int32("myId", myId)) + app.logger.Warn("authMiddleware: OAuth JWT validation failed", zap.Error(err)) } - } else { - app.logger.Warn("authMiddleware: OAuth JWT validation failed", zap.Error(err)) } - } - // PKCE token fallback: resolve opaque Bearer token from oauth_tokens - if wallet == "" && bearerToken != "" { - if entry, ok := app.lookupOAuthAccessToken(c, bearerToken); ok { - if myId == 0 || entry.UserID == myId { - wallet = strings.ToLower(entry.ClientID) - c.Locals("oauthScope", entry.Scope) - if myId == 0 { - myId = entry.UserID - c.Locals("myId", int(entry.UserID)) + + // PKCE token fallback: resolve opaque Bearer token from oauth_tokens in case the getSigner fails because there's no secret stored in the api_keys table + if wallet == "" { + if entry, ok := app.lookupOAuthAccessToken(c, bearerToken); ok { + if myId == 0 || entry.UserID == myId { + wallet = strings.ToLower(entry.ClientID) + c.Locals("oauthScope", entry.Scope) + if myId == 0 { + myId = entry.UserID + c.Locals("myId", int(entry.UserID)) + } + app.logger.Debug("authMiddleware: resolved via PKCE token", zap.String("wallet", wallet), zap.Int32("userId", myId)) + } else { + app.logger.Warn("authMiddleware: PKCE token userId does not match myId", zap.Int32("tokenUserId", entry.UserID), zap.Int32("myId", myId)) } } else { - app.logger.Warn("authMiddleware: PKCE token userId does not match myId", zap.Int32("tokenUserId", entry.UserID), zap.Int32("myId", myId)) + app.logger.Debug("authMiddleware: PKCE token lookup failed") } - } else { - app.logger.Debug("authMiddleware: PKCE token lookup failed") } } + + if wallet == "" { + // Try to get signer of headers for legacy signed requests + wallet = app.recoverAuthorityFromSignatureHeaders(c) + if wallet != "" { + app.logger.Debug("authMiddleware: resolved via signature headers", zap.String("wallet", wallet)) + } + } + + // If still no wallet, we couldn't resolve an authed wallet from the request + if wallet == "" { + app.logger.Debug("authMiddleware: no auth resolved") + } } c.Locals("authedWallet", wallet) diff --git a/api/request_helpers.go b/api/request_helpers.go index 7062cbc7..b20cadb0 100644 --- a/api/request_helpers.go +++ b/api/request_helpers.go @@ -193,6 +193,14 @@ func (app *ApiServer) getSignerFromOAuthToken(c *fiber.Ctx, token string) *Signe if err != nil { return nil } + + // Populate oauthScope and myId on the context so authMiddleware doesn't need + // a second PKCE token lookup when the signer was resolved via an OAuth token. + c.Locals("oauthScope", entry.Scope) + if myId, _ := c.Locals("myId").(int); myId == 0 { + c.Locals("myId", int(entry.UserID)) + } + return &Signer{ Address: strings.ToLower(entry.ClientID), PrivateKey: privateKey, diff --git a/api/v1_oauth_test.go b/api/v1_oauth_test.go index 7ceb84df..83c8b08e 100644 --- a/api/v1_oauth_test.go +++ b/api/v1_oauth_test.go @@ -1246,3 +1246,33 @@ func TestAuthMiddleware_PKCEToken_UserIDMismatch(t *testing.T) { // Should be rejected because the token's user_id (100) does not match the requested user_id (200) assert.Equal(t, 403, res.StatusCode) } + +// TestOAuthMe_WithApiSecret tests the /me endpoint when the PKCE token belongs to a developer +// app that also has an api_secret registered in api_keys. In this case getApiSigner resolves the +// signer via getSignerFromOAuthToken, which must populate myId and oauthScope so that /me can +// resolve the authenticated user without a second PKCE token lookup. +func TestOAuthMe_WithApiSecret(t *testing.T) { + app := emptyTestApp(t) + clientID := seedOAuthTestData(t, app) + + // Register a private key as the api_secret for this developer app. + // Using the well-known ganache key; the address must match clientID. + const apiSecret = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ensureApiKeysTables(t, app, context.Background()) + _, err := app.writePool.Exec(context.Background(), ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ($1, $2, 10, 500000) + ON CONFLICT (api_key) DO UPDATE SET api_secret = EXCLUDED.api_secret + `, clientID, apiSecret) + require.NoError(t, err) + + accessToken, _ := insertTestTokens(t, app, clientID, 100, "read", "family-apisecret", time.Hour, 30*24*time.Hour) + + status, body := oauthGetWithBearer(t, app, "/v1/me", accessToken) + + assert.Equal(t, 200, status) + jsonAssert(t, body, map[string]any{ + "data.handle": "oauthuser", + "data.name": "OAuth User", + }) +}