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

[RFC7662] Add introspect endpoint to introspect access & refresh token #3404

Merged
merged 10 commits into from
Mar 28, 2024
82 changes: 82 additions & 0 deletions server/introspection.go
supercairos marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package server

type IntrospectionExtra struct {
AuthorizingParty string `json:"azp,omitempty"`

Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`

Groups []string `json:"groups,omitempty"`

Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`

FederatedIDClaims *federatedIDClaims `json:"federated_claims,omitempty"`
}

// Introspection contains an access token's session data as specified by
// [IETF RFC 7662](https://tools.ietf.org/html/rfc7662)
type Introspection struct {
// Boolean indicator of whether or not the presented token
// is currently active. The specifics of a token's "active" state
// will vary depending on the implementation of the authorization
// server and the information it keeps about its tokens, but a "true"
// value return for the "active" property will generally indicate
// that a given token has been issued by this authorization server,
// has not been revoked by the resource owner, and is within its
// given time window of validity (e.g., after its issuance time and
// before its expiration time).
Active bool `json:"active"`

// JSON string containing a space-separated list of
// scopes associated with this token.
Scope string `json:"scope,omitempty"`

// Client identifier for the OAuth 2.0 client that
// requested this token.
ClientID string `json:"client_id"`

// Subject of the token, as defined in JWT [RFC7519].
// Usually a machine-readable identifier of the resource owner who
// authorized this token.
Subject string `json:"sub"`

// Integer timestamp, measured in the number of seconds
// since January 1 1970 UTC, indicating when this token will expire.
Expiry int64 `json:"exp"`

// Integer timestamp, measured in the number of seconds
// since January 1 1970 UTC, indicating when this token was
// originally issued.
IssuedAt int64 `json:"iat"`

// Integer timestamp, measured in the number of seconds
// since January 1 1970 UTC, indicating when this token is not to be
// used before.
NotBefore int64 `json:"nbf"`

// Human-readable identifier for the resource owner who
// authorized this token.
Username string `json:"username,omitempty"`

// Service-specific string identifier or list of string
// identifiers representing the intended audience for this token, as
// defined in JWT
Audience audience `json:"aud"`

// String representing the issuer of this token, as
// defined in JWT
Issuer string `json:"iss"`

// String identifier for the token, as defined in JWT [RFC7519].
JwtTokenID string `json:"jti,omitempty"`

// TokenType is the introspected token's type, typically `bearer`.
TokenType string `json:"token_type"`

// TokenUse is the introspected token's use, for example `access_token` or `refresh_token`.
TokenUse string `json:"token_use"`
supercairos marked this conversation as resolved.
Show resolved Hide resolved

// Extra is arbitrary data set from the token claims.
Extra IntrospectionExtra `json:"ext,omitempty"`
supercairos marked this conversation as resolved.
Show resolved Hide resolved
}
219 changes: 219 additions & 0 deletions server/introspectionhandler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package server

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"

"github.com/coreos/go-oidc/v3/oidc"

"github.com/dexidp/dex/server/internal"
)

type introspectionError struct {
typ string
code int
desc string
}

func (e *introspectionError) Error() string {
return fmt.Sprintf("introspection error: status %d, %q %s", e.code, e.typ, e.desc)
}

func (e *introspectionError) Is(tgt error) bool {
target, ok := tgt.(*introspectionError)
if !ok {
return false
}

return e.typ == target.typ &&
e.code == target.code &&
e.desc == target.desc
}

func newIntrospectInactiveTokenError() *introspectionError {
return &introspectionError{typ: errInactiveToken, desc: "", code: http.StatusUnauthorized}
}

func newIntrospectInternalServerError() *introspectionError {
return &introspectionError{typ: errServerError, desc: "", code: http.StatusInternalServerError}
}

func newIntrospectBadRequestError(desc string) *introspectionError {
return &introspectionError{typ: errInvalidRequest, desc: desc, code: http.StatusBadRequest}
}

func (s *Server) getTokenFromRequest(r *http.Request) (string, string, error) {
if r.Method != "POST" {
return "", "", newIntrospectBadRequestError(fmt.Sprintf("HTTP method is \"%s\", expected \"POST\".", r.Method))
} else if err := r.ParseForm(); err != nil {
return "", "", newIntrospectBadRequestError("Unable to parse HTTP body, make sure to send a properly formatted form request body.")
} else if r.PostForm == nil || len(r.PostForm) == 0 {
return "", "", newIntrospectBadRequestError("The POST body can not be empty.")
} else if !r.PostForm.Has("token") {
return "", "", newIntrospectBadRequestError("The POST body doesn't contain 'token' parameter.")
}

return r.PostForm.Get("token"), r.PostForm.Get("token_type_hint"), nil
}

func (s *Server) introspectRefreshToken(_ context.Context, token string) (*Introspection, error) {
rToken := new(internal.RefreshToken)
if err := internal.Unmarshal(token, rToken); err != nil {
// For backward compatibility, assume the introspection_token is a raw introspection token ID
// if it fails to decode.
//
// Because introspection_token values that aren't unmarshable were generated by servers
// that don't have a Token value, we'll still reject any attempts to claim a
// introspection_token twice.
rToken = &internal.RefreshToken{RefreshId: token, Token: ""}
}

rCtx, err := s.getRefreshTokenFromStorage(nil, rToken)
if err != nil {
if errors.Is(err, invalidErr) || errors.Is(err, expiredErr) {
return nil, newIntrospectInactiveTokenError()
}

s.logger.Errorf("failed to get refresh token: %v", err)
return nil, newIntrospectInternalServerError()
}

subjectString, sErr := genSubject(rCtx.storageToken.Claims.UserID, rCtx.storageToken.ConnectorID)
if sErr != nil {
s.logger.Errorf("failed to marshal offline session ID: %v", err)
return nil, newIntrospectInternalServerError()
}

return &Introspection{
Active: true,
ClientID: rCtx.storageToken.ClientID,
IssuedAt: rCtx.storageToken.CreatedAt.Unix(),
NotBefore: rCtx.storageToken.CreatedAt.Unix(),
Expiry: rCtx.storageToken.CreatedAt.Add(s.refreshTokenPolicy.absoluteLifetime).Unix(),
Subject: subjectString,
Username: rCtx.storageToken.Claims.PreferredUsername,
Audience: getAudience(rCtx.storageToken.ClientID, rCtx.scopes),
Issuer: s.issuerURL.String(),

Extra: IntrospectionExtra{
Email: rCtx.storageToken.Claims.Email,
EmailVerified: &rCtx.storageToken.Claims.EmailVerified,
Groups: rCtx.storageToken.Claims.Groups,
Name: rCtx.storageToken.Claims.Username,
PreferredUsername: rCtx.storageToken.Claims.PreferredUsername,
},
TokenType: "Bearer",
TokenUse: "refresh_token",
}, nil
}

func (s *Server) introspectAccessToken(ctx context.Context, token string) (*Introspection, error) {
verifier := oidc.NewVerifier(s.issuerURL.String(), &storageKeySet{s.storage}, &oidc.Config{SkipClientIDCheck: true})
idToken, err := verifier.Verify(ctx, token)
if err != nil {
return nil, newIntrospectInactiveTokenError()
}

var claims IntrospectionExtra
if err := idToken.Claims(&claims); err != nil {
supercairos marked this conversation as resolved.
Show resolved Hide resolved
return nil, newIntrospectInternalServerError()
}

clientID, err := getClientID(idToken.Audience, claims.AuthorizingParty)
if err != nil {
return nil, newIntrospectInternalServerError()
}

client, err := s.storage.GetClient(clientID)
if err != nil {
return nil, newIntrospectInternalServerError()
}

return &Introspection{
Active: true,
ClientID: client.ID,
IssuedAt: idToken.IssuedAt.Unix(),
NotBefore: idToken.IssuedAt.Unix(),
Expiry: idToken.Expiry.Unix(),
Subject: idToken.Subject,
Username: claims.PreferredUsername,
Audience: idToken.Audience,
Issuer: s.issuerURL.String(),

Extra: claims,
TokenType: "Bearer",
TokenUse: "access_token",
}, nil
}

func (s *Server) handleIntrospect(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

var introspect *Introspection
token, tokenType, err := s.getTokenFromRequest(r)
if err == nil {
switch tokenType {
case "access_token":
introspect, err = s.introspectAccessToken(ctx, token)
case "refresh_token":
introspect, err = s.introspectRefreshToken(ctx, token)
default:
// Check token as an Access Token first, then as a introspection Token
introspect, err = s.introspectAccessToken(ctx, token)
if introspect == nil {
introspect, err = s.introspectRefreshToken(ctx, token)
}
supercairos marked this conversation as resolved.
Show resolved Hide resolved
}
}

if err != nil {
if intErr, ok := err.(*introspectionError); ok {
s.introspectErrHelper(w, intErr.typ, intErr.desc, intErr.code)
} else {
s.logger.Errorf("An unknown error occurred: %s", err.Error())
s.introspectErrHelper(w, errServerError, "An unknown error occurred", http.StatusInternalServerError)
}

return
}

rawJSON, jsonErr := json.Marshal(introspect)
if jsonErr != nil {
s.introspectErrHelper(w, errServerError, jsonErr.Error(), 500)
}

w.Header().Set("Content-Type", "application/json")
w.Write(rawJSON)
}

func (s *Server) introspectErrHelper(w http.ResponseWriter, typ string, description string, statusCode int) {
if typ == errInactiveToken {
if err := introspectInactiveErr(w); err != nil {
s.logger.Errorf("introspect error response: %v", err)
}
return
}

if err := tokenErr(w, typ, description, statusCode); err != nil {
s.logger.Errorf("introspect error response: %v", err)
}
}

func introspectInactiveErr(w http.ResponseWriter) error {
body, err := json.Marshal(struct {
Active bool `json:"active"`
}{Active: false})
if err != nil {
return fmt.Errorf("failed to marshal token error response: %v", err)
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(401)
w.Write(body)
supercairos marked this conversation as resolved.
Show resolved Hide resolved
return nil
}
Loading
Loading