Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions internal/api/oauthserver/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package oauthserver

import (
"net/http"

"github.com/supabase/auth/internal/api/apierrors"
)

// Token endpoint error codes per RFC 6749 Section 5.2, extending the set in authorize.go.
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
const (
oAuth2ErrorInvalidClient = "invalid_client"
oAuth2ErrorInvalidGrant = "invalid_grant"
oAuth2ErrorUnsupportedGrantType = "unsupported_grant_type"
)

// Only codes meaning the grant itself is dead may map to invalid_grant: that is the code telling a client to stop retrying and re-authorize the user.
var grantErrorCodes = map[string]string{
apierrors.ErrorCodeRefreshTokenNotFound: oAuth2ErrorInvalidGrant,
apierrors.ErrorCodeRefreshTokenAlreadyUsed: oAuth2ErrorInvalidGrant,
apierrors.ErrorCodeSessionNotFound: oAuth2ErrorInvalidGrant,
apierrors.ErrorCodeSessionExpired: oAuth2ErrorInvalidGrant,
apierrors.ErrorCodeUserBanned: oAuth2ErrorInvalidGrant,

apierrors.ErrorCodeValidationFailed: oAuth2ErrorInvalidRequest,
}

// oauthTokenError translates an error from the shared token service into an error response the token endpoint is allowed to return, per RFC 6749 Section 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2). The service is shared with /auth/v1/token, whose clients parse the HTTPError shape, so the translation has to happen at this boundary.
func oauthTokenError(err error) error {
switch e := err.(type) {
case nil:
return nil

case *apierrors.OAuthError:
return e

case *apierrors.HTTPError:
// A 409, 429 or 5xx says nothing about the grant, and every value in the spec's set asserts something about this request that retrying will not change.
if e.HTTPStatus != http.StatusBadRequest {
return e
}

code, ok := grantErrorCodes[e.ErrorCode]
if !ok {
code = oAuth2ErrorInvalidRequest
}

return apierrors.NewOAuthError(code, e.Message).WithInternalError(e)

case interface{ Cause() error }:
// storage.CommitWithError, used where the transaction must still commit.
if cause := e.Cause(); cause != nil && cause != err {
return oauthTokenError(cause)
}
}

return err
}
91 changes: 91 additions & 0 deletions internal/api/oauthserver/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package oauthserver

import (
"testing"

"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/storage"
)

func TestOAuthTokenError(t *testing.T) {
tests := []struct {
name string
err error
// the expected OAuth error code, empty when the error should pass through untranslated
expected string
// the expected error_description, when it needs asserting
expectedDescription string
// the status an untranslated error must keep
expectedStatus int
}{
{
name: "refresh token not found should return invalid_grant",
err: apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenNotFound, "Invalid Refresh Token: Refresh Token Not Found"),
expected: "invalid_grant",
},
{
name: "session expired should return invalid_grant",
err: apierrors.NewBadRequestError(apierrors.ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired"),
expected: "invalid_grant",
},
{
name: "refresh token reuse wrapped in CommitWithError should return invalid_grant",
err: storage.NewCommitWithError(apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenAlreadyUsed, "Invalid Refresh Token: Already Used")),
expected: "invalid_grant",
},
{
name: "the internal message should not reach the client",
err: apierrors.NewBadRequestError(apierrors.ErrorCodeRefreshTokenAlreadyUsed, "Invalid Refresh Token: Already Used").WithInternalMessage("Possible abuse attempt: %v", "6a3c1f0e"),
expected: "invalid_grant",
expectedDescription: "Invalid Refresh Token: Already Used",
},
{
name: "validation failure should return invalid_request",
err: apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid Refresh Token: Not Issued By This Server"),
expected: "invalid_request",
},
{
name: "unrecognized error code should return invalid_request, not invalid_grant",
err: apierrors.NewBadRequestError("some_future_error_code", "Something new"),
expected: "invalid_request",
},
{
name: "concurrent refresh should pass through as a 409",
err: apierrors.NewConflictError("Too many concurrent token refresh requests on the same session or refresh token"),
expectedStatus: 409,
},
{
name: "internal failure should pass through as a 500",
err: apierrors.NewInternalServerError("error generating jwt token"),
expectedStatus: 500,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := oauthTokenError(tt.err)

if tt.expected == "" {
httpErr, ok := result.(*apierrors.HTTPError)
if !ok {
t.Fatalf("oauthTokenError() = %T, expected the error to pass through as *apierrors.HTTPError", result)
}
if httpErr.HTTPStatus != tt.expectedStatus {
t.Errorf("oauthTokenError() status = %v, expected %v", httpErr.HTTPStatus, tt.expectedStatus)
}
return
}

oauthErr, ok := result.(*apierrors.OAuthError)
if !ok {
t.Fatalf("oauthTokenError() = %T, expected *apierrors.OAuthError", result)
}
if oauthErr.Err != tt.expected {
t.Errorf("oauthTokenError() error = %v, expected %v", oauthErr.Err, tt.expected)
}
if tt.expectedDescription != "" && oauthErr.Description != tt.expectedDescription {
t.Errorf("oauthTokenError() error_description = %v, expected %v", oauthErr.Description, tt.expectedDescription)
}
})
}
}
42 changes: 21 additions & 21 deletions internal/api/oauthserver/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,12 +271,12 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error {
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") {
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
return apierrors.NewOAuthError("invalid_request", "Invalid JSON body")
return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "Invalid JSON body")
}
} else {
// Parse form data
if err := r.ParseForm(); err != nil {
return apierrors.NewOAuthError("invalid_request", "Failed to parse form data")
return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "Failed to parse form data")
}

params.GrantType = r.FormValue("grant_type")
Expand All @@ -290,17 +290,17 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error {

// Validate grant_type
if params.GrantType == "" {
return apierrors.NewOAuthError("invalid_request", "grant_type is required")
return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "grant_type is required")
}

client := shared.GetOAuthServerClient(ctx)
if client == nil {
return apierrors.NewOAuthError("invalid_client", "Client authentication required")
return apierrors.NewOAuthError(oAuth2ErrorInvalidClient, "Client authentication required")
}

// Validate that the authenticated client is allowed to use the requested grant type
if !client.IsGrantTypeAllowed(params.GrantType) {
return apierrors.NewOAuthError("unsupported_grant_type", "Client is not allowed to use grant type: "+params.GrantType)
return apierrors.NewOAuthError(oAuth2ErrorUnsupportedGrantType, "Client is not allowed to use grant type: "+params.GrantType)
}

switch params.GrantType {
Expand All @@ -309,20 +309,20 @@ func (s *Server) OAuthToken(w http.ResponseWriter, r *http.Request) error {
case GrantTypeRefreshToken:
return s.handleRefreshTokenGrant(ctx, w, r, &params)
default:
return apierrors.NewOAuthError("unsupported_grant_type", "Unsupported grant type: "+params.GrantType)
return apierrors.NewOAuthError(oAuth2ErrorUnsupportedGrantType, "Unsupported grant type: "+params.GrantType)
}
}

// handleAuthorizationCodeGrant handles the authorization_code grant type
func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.ResponseWriter, r *http.Request, params *OAuthTokenParams) error {
if params.Code == "" {
return apierrors.NewOAuthError("invalid_request", "code is required for authorization_code grant")
return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "code is required for authorization_code grant")
}

// Get authenticated client from middleware
client := shared.GetOAuthServerClient(ctx)
if client == nil {
return apierrors.NewOAuthError("invalid_client", "Client authentication required")
return apierrors.NewOAuthError(oAuth2ErrorInvalidClient, "Client authentication required")
}

// Exchange authorization code for tokens
Expand All @@ -336,51 +336,51 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon
authorization, err := models.FindOAuthServerAuthorizationByCode(db, params.Code)
if err != nil {
if models.IsNotFoundError(err) {
return apierrors.NewOAuthError("invalid_grant", "Invalid authorization code")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid authorization code")
}
return apierrors.NewInternalServerError("Error finding authorization code").WithInternalError(err)
}

// Check if the authorization has expired
if authorization.IsExpired() {
return apierrors.NewOAuthError("invalid_grant", "Authorization code has expired")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code has expired")
}

// Validate that the authorization code was issued for this client
if authorization.ClientID != client.ID {
return apierrors.NewOAuthError("invalid_grant", "Authorization code was not issued for this client")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code was not issued for this client")
}

// Validate that (if exists) the resource parameter matches the authorization code resource
if params.Resource != "" && params.Resource != utilities.StringValue(authorization.Resource) {
return apierrors.NewOAuthError("invalid_grant", "Authorization code resource does not match the resource parameter")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code resource does not match the resource parameter")
}

// Validate redirect_uri if provided - must match the one used in authorization
if params.RedirectURI != "" && params.RedirectURI != authorization.RedirectURI {
return apierrors.NewOAuthError("invalid_grant", "Invalid redirect_uri")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid redirect_uri")
}

// Validate PKCE if used in the authorization
if err := authorization.VerifyPKCE(params.CodeVerifier); err != nil {
return apierrors.NewOAuthError("invalid_grant", "PKCE verification failed: "+err.Error())
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "PKCE verification failed: "+err.Error())
}

// Get the user for the authorization code
if authorization.UserID == nil {
return apierrors.NewOAuthError("invalid_grant", "Authorization code has no associated user")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Authorization code has no associated user")
}

user, err := models.FindUserByID(db, *authorization.UserID)
if err != nil {
if models.IsNotFoundError(err) {
return apierrors.NewOAuthError("invalid_grant", "User not found for authorization code")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "User not found for authorization code")
}
return apierrors.NewInternalServerError("Error finding user").WithInternalError(err)
}

if user.IsBanned() {
return apierrors.NewOAuthError("access_denied", "User is banned")
return apierrors.NewOAuthError(oAuth2ErrorAccessDenied, "User is banned")
}

// Exchange the authorization code for tokens
Expand All @@ -396,7 +396,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon
err = db.Transaction(func(tx *storage.Connection) error {
if _, terr := models.FindOAuthServerAuthorizationByIDForUpdate(tx, authorization.AuthorizationID); terr != nil {
if models.IsNotFoundError(terr) {
return apierrors.NewOAuthError("invalid_grant", "Invalid authorization code")
return apierrors.NewOAuthError(oAuth2ErrorInvalidGrant, "Invalid authorization code")
}
return apierrors.NewInternalServerError("Error locking authorization code").WithInternalError(terr)
}
Expand Down Expand Up @@ -429,7 +429,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon

if err != nil {
if httpErr, ok := err.(*apierrors.HTTPError); ok {
return httpErr
return oauthTokenError(httpErr)
}
if oauthErr, ok := err.(*apierrors.OAuthError); ok {
return oauthErr
Expand Down Expand Up @@ -478,7 +478,7 @@ func (s *Server) handleAuthorizationCodeGrant(ctx context.Context, w http.Respon
// handleRefreshTokenGrant handles the refresh_token grant type
func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.Request, params *OAuthTokenParams) error {
if params.RefreshToken == "" {
return apierrors.NewOAuthError("invalid_request", "refresh_token is required for refresh_token grant")
return apierrors.NewOAuthError(oAuth2ErrorInvalidRequest, "refresh_token is required for refresh_token grant")
}

// Use the token service to handle refresh token grant
Expand All @@ -499,7 +499,7 @@ func (s *Server) handleRefreshTokenGrant(ctx context.Context, w http.ResponseWri
ClientID: clientID,
})
if err != nil {
return err
return oauthTokenError(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question: is there a test covering this to ensure the error key is in the response body?

}

// Convert to OAuth-compliant response format (exclude user info for OAuth clients)
Expand Down
Loading