Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
introspectionhandler.go 10.85 KiB
package server

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

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

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

// 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"`

	// Extra is arbitrary data set from the token claims.
	Extra IntrospectionExtra `json:"ext,omitempty"`
}

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"`
}

type TokenTypeEnum int

const (
	AccessToken TokenTypeEnum = iota
	RefreshToken
)

func (t TokenTypeEnum) String() string {
	switch t {
	case AccessToken:
		return "access_token"
	case RefreshToken:
		return "refresh_token"
	default:
		return fmt.Sprintf("TokenTypeEnum(%d)", t)
	}
}

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) guessTokenType(ctx context.Context, token string) (TokenTypeEnum, error) {
	// We skip every checks, we only want to know if it's a valid JWT
	verifierConfig := oidc.Config{
		SkipClientIDCheck: true,
		SkipExpiryCheck:   true,
		SkipIssuerCheck:   true,

		// We skip signature checks to avoid database calls;
		InsecureSkipSignatureCheck: true,
	}

	verifier := oidc.NewVerifier(s.issuerURL.String(), nil, &verifierConfig)
	if _, err := verifier.Verify(ctx, token); err != nil {
		// If it's not an access token, let's assume it's a refresh token;
		return RefreshToken, nil
	}

	// If it's a valid JWT, it's an access token.
	return AccessToken, nil
}

func (s *Server) getTokenFromRequest(r *http.Request) (string, TokenTypeEnum, error) {
	if r.Method != "POST" {
		return "", 0, newIntrospectBadRequestError(fmt.Sprintf("HTTP method is \"%s\", expected \"POST\".", r.Method))
	} else if err := r.ParseForm(); err != nil {
		return "", 0, 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 "", 0, newIntrospectBadRequestError("The POST body can not be empty.")
	} else if !r.PostForm.Has("token") {
		return "", 0, newIntrospectBadRequestError("The POST body doesn't contain 'token' parameter.")
	}

	token := r.PostForm.Get("token")
	tokenType, err := s.guessTokenType(r.Context(), token)
	if err != nil {
		s.logger.Error(err)
		return "", 0, newIntrospectInternalServerError()
	}

	requestTokenType := r.PostForm.Get("token_type_hint")
	if requestTokenType != "" {
		if tokenType.String() != requestTokenType {
			s.logger.Warnf("Token type hint doesn't match token type: %s != %s", requestTokenType, tokenType)
		}
	}

	return token, tokenType, 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 refresh_token is a raw refresh token ID
		// if it fails to decode.
		//
		// Because refresh_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
		// refresh_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 {
		s.logger.Errorf("Error while fetching token claims: %s", err.Error())
		return nil, newIntrospectInternalServerError()
	}

	clientID, err := getClientID(idToken.Audience, claims.AuthorizingParty)
	if err != nil {
		s.logger.Error("Error while fetching client_id from token: %s", err.Error())
		return nil, newIntrospectInternalServerError()
	}

	client, err := s.storage.GetClient(clientID)
	if err != nil {
		s.logger.Error("Error while fetching client from storage: %s", err.Error())
		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 AccessToken:
			introspect, err = s.introspectAccessToken(ctx, token)
		case RefreshToken:
			introspect, err = s.introspectRefreshToken(ctx, token)
		default:
			// Token type is neither handled token types.
			s.logger.Errorf("Unknown token type: %s", tokenType)
			introspectInactiveErr(w)
			return
		}
	}

	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 {
		introspectInactiveErr(w)
		return
	}

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

func introspectInactiveErr(w http.ResponseWriter) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Pragma", "no-cache")
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(200)
	json.NewEncoder(w).Encode(struct {
		Active bool `json:"active"`
	}{Active: false})
}