Introspect OAuth2 refresh tokens
RFC 7662 lets clients introspect any OAuth2 token, but the endpoint only resolved access tokens. Look up refresh tokens too, honor the optional token_type_hint to drive lookup order with a fallback to the other table, and report revoked or expired refresh tokens as inactive. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -785,6 +785,171 @@ func TestOAuth2_Introspect(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusUnauthorized, raw.StatusCode)
|
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")
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -717,11 +717,24 @@ func OAuth2UserInfoRaw(
|
|||||||
func OAuth2Introspect(
|
func OAuth2Introspect(
|
||||||
c *Client,
|
c *Client,
|
||||||
clientID, clientSecret, token string,
|
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) {
|
) (*OAuth2IntrospectResponse, *OAuth2HTTPResponse, error) {
|
||||||
values := url.Values{
|
values := url.Values{
|
||||||
"token": {token},
|
"token": {token},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tokenTypeHint != "" {
|
||||||
|
values.Set("token_type_hint", tokenTypeHint)
|
||||||
|
}
|
||||||
|
|
||||||
raw, err := postFormWithBasicAuth(
|
raw, err := postFormWithBasicAuth(
|
||||||
c.HTTPClient(),
|
c.HTTPClient(),
|
||||||
oauth2BaseURL(c)+"/introspect",
|
oauth2BaseURL(c)+"/introspect",
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ type (
|
|||||||
IDToken string
|
IDToken string
|
||||||
Scope 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 {
|
func WithAccessTokenDuration(d time.Duration) Option {
|
||||||
@@ -1142,35 +1151,103 @@ func (s *Service) LoadAccessToken(ctx context.Context, tokenValue string) (*core
|
|||||||
return &token, nil
|
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 (
|
var (
|
||||||
hashedValue = hash.SHA256String(tokenValue)
|
hashedValue = hash.SHA256String(tokenValue)
|
||||||
token = coredata.OAuth2AccessToken{}
|
|
||||||
now = time.Now()
|
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(
|
if err := s.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
if err := token.LoadByHashedValueAndClientID(ctx, conn, hashedValue, clientID); err != nil {
|
if preferRefresh {
|
||||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
if err := loadRefresh(ctx, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasRefresh {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return loadAccess(ctx, conn)
|
||||||
return fmt.Errorf("cannot load access token: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := loadAccess(ctx, conn); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasAccess {
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
return loadRefresh(ctx, conn)
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
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 nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &token, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UserInfo(
|
func (s *Service) UserInfo(
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ func (h *OAuth2Handler) IntrospectHandler(w http.ResponseWriter, r *http.Request
|
|||||||
r.Context(),
|
r.Context(),
|
||||||
client.ID,
|
client.ID,
|
||||||
in.Token,
|
in.Token,
|
||||||
|
in.TokenTypeHint,
|
||||||
)
|
)
|
||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse())
|
httpserver.RenderJSON(w, http.StatusOK, types.InactiveIntrospectResponse())
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"go.probo.inc/probo/pkg/coredata"
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
|
"go.probo.inc/probo/pkg/iam/oauth2server"
|
||||||
"go.probo.inc/probo/pkg/uri"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ type (
|
|||||||
|
|
||||||
OAuth2IntrospectInput struct {
|
OAuth2IntrospectInput struct {
|
||||||
Token string
|
Token string
|
||||||
|
TokenTypeHint *coredata.OAuth2TokenTypeHint
|
||||||
}
|
}
|
||||||
|
|
||||||
OAuth2RevokeInput struct {
|
OAuth2RevokeInput struct {
|
||||||
@@ -138,6 +140,14 @@ func (in *OAuth2IntrospectInput) DecodeForm(r *http.Request) error {
|
|||||||
if in.Token == "" {
|
if in.Token == "" {
|
||||||
return fmt.Errorf("missing token parameter")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,14 +327,14 @@ func InactiveIntrospectResponse() *OAuth2IntrospectResponse {
|
|||||||
return &OAuth2IntrospectResponse{Active: false}
|
return &OAuth2IntrospectResponse{Active: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ActiveIntrospectResponse(token *coredata.OAuth2AccessToken) *OAuth2IntrospectResponse {
|
func ActiveIntrospectResponse(result *oauth2server.IntrospectResult) *OAuth2IntrospectResponse {
|
||||||
return &OAuth2IntrospectResponse{
|
return &OAuth2IntrospectResponse{
|
||||||
Active: true,
|
Active: true,
|
||||||
Scope: token.Scopes,
|
Scope: result.Scopes,
|
||||||
ClientID: token.ClientID,
|
ClientID: result.ClientID,
|
||||||
Sub: token.IdentityID,
|
Sub: result.IdentityID,
|
||||||
Exp: token.ExpiresAt.Unix(),
|
Exp: result.ExpiresAt.Unix(),
|
||||||
Iat: token.CreatedAt.Unix(),
|
Iat: result.IssuedAt.Unix(),
|
||||||
TokenType: "Bearer",
|
TokenType: result.TokenType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user