Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: token introspection #11

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Several differences or intended differences exist between this package and the g
- Add support for:
- [ ] [JWT Secured Authorization Response Mode for OAuth 2.0](https://openid.net/specs/oauth-v2-jarm.html) (JARM) implementation.
- [ ] [OpenID Connect 1.0 Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) implementation.
- [ ] [RFC7662: OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [x] [RFC7662: OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [x] [RFC7009: OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009)
- [ ] [RFC8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
- [x] [RFC9126: OAuth 2.0 Pushed Authorization Requests (PAR)](https://datatracker.ietf.org/doc/html/rfc9126)
Expand Down
101 changes: 95 additions & 6 deletions internal/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,53 @@ func cloneURLValues(v url.Values) url.Values {
return v2
}

type Introspection struct {
Active bool `json:"active"`
JTI string `json:"jti,omitempty"`
Issuer string `json:"iss,omitempty"`
Scope string `json:"scope,omitempty"`
Audience []string `json:"aud,omitempty"`
ClientID string `json:"client_id,omitempty"`
TokenType string `json:"token_type,omitempty"`
Expiry time.Time `json:"exp,omitempty"`
IssuedAt time.Time `json:"iat,omitempty"`
NotBefore time.Time `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
ErrorURI string `json:"error_uri,omitempty"`
AuthorizedParty string `json:"azp,omitempty"`
Username string `json:"username,omitempty"`
Raw map[string]any `json:"-"`
}

func IntrospectToken(ctx context.Context, clientID, clientSecret, introspectionURL string, v url.Values, authStyle AuthStyle, styleCache *AuthStyleCache) (introspection *Introspection, err error) {
needsAuthStyleProbe := authStyle == 0
if needsAuthStyleProbe {
if style, ok := styleCache.lookupAuthStyle(introspectionURL); ok {
authStyle = style
needsAuthStyleProbe = false
} else {
authStyle = AuthStyleInHeader // the first way we'll try
}
}
req, err := newPOSTRequest(introspectionURL, clientID, clientSecret, v, authStyle)
if err != nil {
return nil, err
}

if introspection, err = doIntrospectRoundTrip(ctx, req); err != nil && needsAuthStyleProbe {
authStyle = AuthStyleInParams // the second way we'll try
req, _ = newPOSTRequest(introspectionURL, clientID, clientSecret, v, authStyle)
introspection, err = doIntrospectRoundTrip(ctx, req)
}
if needsAuthStyleProbe && err == nil {
styleCache.setAuthStyle(introspectionURL, authStyle)
}

return introspection, err
}

func RevokeToken(ctx context.Context, clientID, clientSecret, revocationURL string, v url.Values, authStyle AuthStyle, styleCache *AuthStyleCache) error {
needsAuthStyleProbe := authStyle == 0
if needsAuthStyleProbe {
Expand Down Expand Up @@ -284,6 +331,48 @@ func RetrieveToken(ctx context.Context, clientID, clientSecret, tokenURL string,
return token, err
}

func doIntrospectRoundTrip(ctx context.Context, req *http.Request) (introspection *Introspection, err error) {
r, err := ContextClient(ctx).Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
r.Body.Close()
if err != nil {
return nil, fmt.Errorf("oauth2: cannot introspect token: %v", err)
}

content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))

introspectError := &BaseError{
Response: r,
Body: body,
}

switch content {
case "application/json":
resp := &Introspection{}

if err = json.Unmarshal(body, resp); err != nil {
// TODO: REMOVE.
fmt.Printf("error occurred unmarshalling object: %+v", err)

return nil, introspectError
}

if err = json.Unmarshal(body, &resp.Raw); err != nil {
// TODO: REMOVE.
fmt.Printf("error occurred unmarshalling raw: %+v", err)

return nil, introspectError
}

return resp, nil
}

return nil, introspectError
}

func doRevokeRoundTrip(ctx context.Context, req *http.Request) error {
r, err := ContextClient(ctx).Do(req.WithContext(ctx))
if err != nil {
Expand All @@ -295,13 +384,13 @@ func doRevokeRoundTrip(ctx context.Context, req *http.Request) error {
return fmt.Errorf("oauth2: cannot revoke token: %v", err)
}

failureStatus := r.StatusCode < 200 || r.StatusCode > 299
failureStatus := r.StatusCode < http.StatusOK || r.StatusCode >= http.StatusMultipleChoices

if !failureStatus {
return nil
}

revokeError := &RevokeError{
revokeError := &BaseError{
Response: r,
Body: body,
// attempt to populate error detail below
Expand Down Expand Up @@ -344,7 +433,7 @@ func doTokenRoundTrip(ctx context.Context, req *http.Request) (*Token, error) {
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
}

failureStatus := r.StatusCode < 200 || r.StatusCode > 299
failureStatus := r.StatusCode < http.StatusOK || r.StatusCode >= http.StatusMultipleChoices
retrieveError := &RetrieveError{
Response: r,
Body: body,
Expand Down Expand Up @@ -432,15 +521,15 @@ func (r *RetrieveError) Error() string {
return fmt.Sprintf("oauth2: cannot fetch token: %v\nResponse: %s", r.Response.Status, r.Body)
}

type RevokeError struct {
type BaseError struct {
Response *http.Response
Body []byte
ErrorCode string
ErrorDescription string
ErrorURI string
}

func (r *RevokeError) Error() string {
func (r *BaseError) Error() string {
if r.ErrorCode != "" {
s := fmt.Sprintf("oauth2: %q", r.ErrorCode)
if r.ErrorDescription != "" {
Expand All @@ -451,5 +540,5 @@ func (r *RevokeError) Error() string {
}
return s
}
return fmt.Sprintf("oauth2: cannot revoke token: %v\nResponse: %s", r.Response.Status, r.Body)
return fmt.Sprintf("oauth2: cannot perform flow: %v\nResponse: %s", r.Response.Status, r.Body)
}
93 changes: 93 additions & 0 deletions introspection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package oauth2

import (
"context"
"fmt"
"net/url"

"authelia.com/client/oauth2/internal"
)

type IntrospectionResult struct {
Introspection *internal.Introspection
Error error
}

// IntrospectToken allows for simple token introspection.
func (c *Config) IntrospectToken(ctx context.Context, token *Token, opts ...IntrospectionRevocationOption) (results []IntrospectionResult, err error) {
if token == nil {
return nil, fmt.Errorf("error introspecting token: no token was provided")
}

if c.Endpoint.IntrospectionURL == "" {
return nil, fmt.Errorf("error introspecting token: no introspection endpoint URL was provided")
}

tths := []string{}

for _, opt := range opts {
tths = opt.appendTokenTypeHints(tths)
}

if len(tths) == 0 {
tths = []string{"access_token"}
}

vals := make([]url.Values, len(tths))

for i, tth := range tths {
xvals := url.Values{
"token_type_hint": []string{tth},
}

switch tth {
case "access_token":
if len(token.AccessToken) == 0 {
return nil, fmt.Errorf("error introspecting token: token type hint '%s' can only be introspected for a token that has an access token", tth)
}

xvals.Set("token", token.AccessToken)
case "refresh_token":
if len(token.AccessToken) == 0 {
return nil, fmt.Errorf("error introspecting token: token type hint '%s' can only be introspected for a token that has a refresh token", tth)
}

xvals.Set("token", token.RefreshToken)
default:
return nil, fmt.Errorf("error introspecting token: token type hint '%s' isn't known", tth)
}

for _, opt := range opts {
opt.setValue(xvals)
}

vals[i] = xvals
}

var (
introspection *internal.Introspection
errored bool
)

for _, v := range vals {
if introspection, err = internal.IntrospectToken(ctx, c.ClientID, c.ClientSecret, c.Endpoint.IntrospectionURL, v, internal.AuthStyle(c.Endpoint.AuthStyle), c.authStyleCache.Get()); err != nil {
errored = true

if rErr, ok := err.(*internal.BaseError); ok {
results = append(results, IntrospectionResult{Introspection: introspection, Error: (*BaseError)(rErr)})

continue
}

results = append(results, IntrospectionResult{Introspection: introspection, Error: err})
}

results = append(results, IntrospectionResult{Introspection: introspection})
}

if errored {
return results, fmt.Errorf("error introspecting token: one or more errors occurred check the results for details")
}

return results, nil
}
15 changes: 8 additions & 7 deletions revocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
)

// RevokeToken allows for simple token revocation.
func (c *Config) RevokeToken(ctx context.Context, token *Token, opts ...RevocationOption) (err error) {
func (c *Config) RevokeToken(ctx context.Context, token *Token, opts ...IntrospectionRevocationOption) (err error) {
if token == nil {
return fmt.Errorf("error revoking token: no token was provided")
}
Expand Down Expand Up @@ -61,7 +61,7 @@ func (c *Config) RevokeToken(ctx context.Context, token *Token, opts ...Revocati

for _, v := range vals {
if err = internal.RevokeToken(ctx, c.ClientID, c.ClientSecret, c.Endpoint.RevocationURL, v, internal.AuthStyle(c.Endpoint.AuthStyle), c.authStyleCache.Get()); err != nil {
if rErr, ok := err.(*internal.RevokeError); ok {
if rErr, ok := err.(*internal.BaseError); ok {
xErr := (*BaseError)(rErr)

return &RevokeError{xErr}
Expand All @@ -77,23 +77,24 @@ type RevokeError struct {
*BaseError
}

type RevocationOption interface {
// IntrospectionRevocationOption is an interface that can be utilized for both introspection and revocation requests.
type IntrospectionRevocationOption interface {
setValue(vals url.Values)
appendTokenTypeHints(tths []string) []string
}

// SetRevocationURLParam builds a RevocationOption which passes key/value parameters
// SetRevocationURLParam builds a IntrospectionRevocationOption which passes key/value parameters
// to a provider's revocation endpoint.
func SetRevocationURLParam(key, value string) RevocationOption {
func SetRevocationURLParam(key, value string) IntrospectionRevocationOption {
return setRevocationValue{key, value}
}

// AddRevocationTokenTypes builds a RevocationOption which explicitly adds a token
// AddRevocationTokenTypes builds a IntrospectionRevocationOption which explicitly adds a token
// type hint to the revocation process. By default the oauth2.RevokeToken method
// will perform the access token revocation. If the authorization server requires
// the refresh token is revoked manually then use this option like
// oauth.AddRevocationTokenTypes("access_token", "refresh_token").
func AddRevocationTokenTypes(values ...string) RevocationOption {
func AddRevocationTokenTypes(values ...string) IntrospectionRevocationOption {
return addRevocationTokenTypeHints{values: values}
}

Expand Down