diff --git a/e2e/console/oauth2_test.go b/e2e/console/oauth2_test.go index 8bd2e7535..73377425d 100644 --- a/e2e/console/oauth2_test.go +++ b/e2e/console/oauth2_test.go @@ -785,6 +785,171 @@ func TestOAuth2_Introspect(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, raw.StatusCode) }, ) + + t.Run( + "active refresh token", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + require.NotEmpty(t, tokens.RefreshToken, + "authorization code flow must mint a refresh token") + + introspect, raw, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + + assert.True(t, introspect.Active, + "valid refresh token must introspect as active") + assert.Empty(t, introspect.TokenType, + "refresh tokens have no OAuth2 token_type per RFC 6749 §5.1") + assert.Equal(t, client.ClientID, introspect.ClientID) + assert.NotEmpty(t, introspect.Sub) + assert.Greater(t, introspect.Exp, int64(0)) + assert.NotEmpty(t, introspect.Scope) + }, + ) + + t.Run( + "refresh token with matching hint", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + introspect, raw, err := testutil.OAuth2IntrospectWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + "refresh_token", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + assert.True(t, introspect.Active) + }, + ) + + t.Run( + "access token still resolves with refresh_token hint", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + introspect, raw, err := testutil.OAuth2IntrospectWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.AccessToken, + "refresh_token", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, raw.StatusCode) + assert.True(t, introspect.Active, + "hint is advisory, server must fall back to other token types") + assert.Equal(t, "Bearer", introspect.TokenType) + }, + ) + + t.Run( + "revoked refresh token is inactive", + func(t *testing.T) { + t.Parallel() + + client := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + client.ClientID, + client.ClientSecret, + redirectURI, + ) + + revokeRaw, err := testutil.OAuth2RevokeWithHint( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + "refresh_token", + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, revokeRaw.StatusCode) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + client.ClientID, + client.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active, + "revoked refresh token must introspect as inactive") + }, + ) + + t.Run( + "refresh token from different client is inactive", + func(t *testing.T) { + t.Parallel() + + clientA := factory.CreateOAuth2Client(owner, nil) + clientB := factory.CreateOAuth2Client(owner, nil) + redirectURI := "http://localhost:9999/callback" + + tokens := testutil.OAuth2PerformAuthorizationCodeFlow( + t, + owner, + clientA.ClientID, + clientA.ClientSecret, + redirectURI, + ) + + introspect, _, err := testutil.OAuth2Introspect( + owner, + clientB.ClientID, + clientB.ClientSecret, + tokens.RefreshToken, + ) + require.NoError(t, err) + assert.False(t, introspect.Active, + "refresh token must only be introspectable by its issuing client") + }, + ) } // --------------------------------------------------------------------------- diff --git a/e2e/internal/testutil/oauth2.go b/e2e/internal/testutil/oauth2.go index 9ed54ca5b..7ac50d78b 100644 --- a/e2e/internal/testutil/oauth2.go +++ b/e2e/internal/testutil/oauth2.go @@ -717,11 +717,24 @@ func OAuth2UserInfoRaw( func OAuth2Introspect( c *Client, clientID, clientSecret, token string, +) (*OAuth2IntrospectResponse, *OAuth2HTTPResponse, error) { + return OAuth2IntrospectWithHint(c, clientID, clientSecret, token, "") +} + +// OAuth2IntrospectWithHint introspects a token with an optional +// token_type_hint per RFC 7662. +func OAuth2IntrospectWithHint( + c *Client, + clientID, clientSecret, token, tokenTypeHint string, ) (*OAuth2IntrospectResponse, *OAuth2HTTPResponse, error) { values := url.Values{ "token": {token}, } + if tokenTypeHint != "" { + values.Set("token_type_hint", tokenTypeHint) + } + raw, err := postFormWithBasicAuth( c.HTTPClient(), oauth2BaseURL(c)+"/introspect", diff --git a/pkg/iam/oauth2server/service.go b/pkg/iam/oauth2server/service.go index 58b6627fe..6bbe726db 100644 --- a/pkg/iam/oauth2server/service.go +++ b/pkg/iam/oauth2server/service.go @@ -111,6 +111,15 @@ type ( IDToken string Scope string } + + IntrospectResult struct { + ClientID gid.GID + IdentityID gid.GID + Scopes coredata.OAuth2Scopes + IssuedAt time.Time + ExpiresAt time.Time + TokenType string + } ) func WithAccessTokenDuration(d time.Duration) Option { @@ -1142,35 +1151,103 @@ func (s *Service) LoadAccessToken(ctx context.Context, tokenValue string) (*core return &token, nil } -func (s *Service) IntrospectToken(ctx context.Context, clientID gid.GID, tokenValue string) (*coredata.OAuth2AccessToken, error) { +// IntrospectToken looks up the token (access or refresh) bound to clientID and +// returns its claims when active. It supports the optional token_type_hint +// from RFC 7662 §2.1: when set, the hinted table is searched first and the +// other one is used as a fallback. A nil result means the token is unknown, +// expired, revoked, or does not belong to clientID and must be reported as +// inactive. +func (s *Service) IntrospectToken( + ctx context.Context, + clientID gid.GID, + tokenValue string, + tokenTypeHint *coredata.OAuth2TokenTypeHint, +) (*IntrospectResult, error) { var ( - hashedValue = hash.SHA256String(tokenValue) - token = coredata.OAuth2AccessToken{} - now = time.Now() + hashedValue = hash.SHA256String(tokenValue) + now = time.Now() + accessToken = coredata.OAuth2AccessToken{} + refreshToken = coredata.OAuth2RefreshToken{} + hasAccess bool + hasRefresh bool ) + loadAccess := func(ctx context.Context, conn pg.Querier) error { + if err := accessToken.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil + } + return fmt.Errorf("cannot load access token: %w", err) + } + hasAccess = true + return nil + } + + loadRefresh := func(ctx context.Context, conn pg.Querier) error { + if err := refreshToken.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil + } + return fmt.Errorf("cannot load refresh token: %w", err) + } + hasRefresh = true + return nil + } + + preferRefresh := tokenTypeHint != nil && *tokenTypeHint == coredata.OAuth2TokenTypeHintRefreshToken + if err := s.pg.WithConn( ctx, func(ctx context.Context, conn pg.Querier) error { - if err := token.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { + if preferRefresh { + if err := loadRefresh(ctx, conn); err != nil { + return err + } + if hasRefresh { return nil } - - return fmt.Errorf("cannot load access token: %w", err) + return loadAccess(ctx, conn) } - return nil + if err := loadAccess(ctx, conn); err != nil { + return err + } + if hasAccess { + return nil + } + return loadRefresh(ctx, conn) }, ); err != nil { return nil, err } - if token.ID == gid.Nil || now.After(token.ExpiresAt) { + switch { + case hasAccess: + if now.After(accessToken.ExpiresAt) { + return nil, nil + } + return &IntrospectResult{ + ClientID: accessToken.ClientID, + IdentityID: accessToken.IdentityID, + Scopes: accessToken.Scopes, + IssuedAt: accessToken.CreatedAt, + ExpiresAt: accessToken.ExpiresAt, + TokenType: tokenTypeBearer, + }, nil + case hasRefresh: + if refreshToken.RevokedAt != nil || now.After(refreshToken.ExpiresAt) { + return nil, nil + } + return &IntrospectResult{ + ClientID: refreshToken.ClientID, + IdentityID: refreshToken.IdentityID, + Scopes: refreshToken.Scopes, + IssuedAt: refreshToken.CreatedAt, + ExpiresAt: refreshToken.ExpiresAt, + }, nil + default: return nil, nil } - - return &token, nil } func (s *Service) UserInfo( diff --git a/pkg/server/api/connect/v1/oauth2_handler.go b/pkg/server/api/connect/v1/oauth2_handler.go index 6d7d50104..dad472a03 100644 --- a/pkg/server/api/connect/v1/oauth2_handler.go +++ b/pkg/server/api/connect/v1/oauth2_handler.go @@ -252,6 +252,7 @@ func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request r.Context(), client.ID, in.Token, + in.TokenTypeHint, ) if err != nil || result == nil { httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse()) diff --git a/pkg/server/api/connect/v1/types/oauth2.go b/pkg/server/api/connect/v1/types/oauth2.go index 3526d9d10..bc6f935b5 100644 --- a/pkg/server/api/connect/v1/types/oauth2.go +++ b/pkg/server/api/connect/v1/types/oauth2.go @@ -21,6 +21,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/oauth2server" "go.probo.inc/probo/pkg/uri" ) @@ -59,7 +60,8 @@ type ( } OAuth2IntrospectInput struct { - Token string + Token string + TokenTypeHint *coredata.OAuth2TokenTypeHint } OAuth2RevokeInput struct { @@ -138,6 +140,14 @@ func (in *OAuth2IntrospectInput) DecodeForm(r *http.Request) error { if in.Token == "" { return fmt.Errorf("missing token parameter") } + + if hint := r.FormValue("token_type_hint"); hint != "" { + h := coredata.OAuth2TokenTypeHint(hint) + if h.IsValid() { + in.TokenTypeHint = &h + } + } + return nil } @@ -317,14 +327,14 @@ func InactiveIntrospectResponse() *OAuth2IntrospectResponse { return &OAuth2IntrospectResponse{Active: false} } -func ActiveIntrospectResponse(token *coredata.OAuth2AccessToken) *OAuth2IntrospectResponse { +func ActiveIntrospectResponse(result *oauth2server.IntrospectResult) *OAuth2IntrospectResponse { return &OAuth2IntrospectResponse{ Active: true, - Scope: token.Scopes, - ClientID: token.ClientID, - Sub: token.IdentityID, - Exp: token.ExpiresAt.Unix(), - Iat: token.CreatedAt.Unix(), - TokenType: "Bearer", + Scope: result.Scopes, + ClientID: result.ClientID, + Sub: result.IdentityID, + Exp: result.ExpiresAt.Unix(), + Iat: result.IssuedAt.Unix(), + TokenType: result.TokenType, } }