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 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.ErrorContext(r.Context(), "failed to guess token type", "err", err) return "", 0, newIntrospectInternalServerError() } requestTokenType := r.PostForm.Get("token_type_hint") if requestTokenType != "" { if tokenType.String() != requestTokenType { s.logger.Warn("token type hint doesn't match token type", "request_token_type", requestTokenType, "token_type", tokenType) } } return token, tokenType, nil } func (s *Server) introspectRefreshToken(ctx 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(ctx, nil, rToken) if err != nil { if errors.Is(err, invalidErr) || errors.Is(err, expiredErr) { return nil, newIntrospectInactiveTokenError() } s.logger.ErrorContext(ctx, "failed to get refresh token", "err", err) return nil, newIntrospectInternalServerError() } subjectString, sErr := genSubject(rCtx.storageToken.Claims.UserID, rCtx.storageToken.ConnectorID) if sErr != nil { s.logger.ErrorContext(ctx, "failed to marshal offline session ID", "err", 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(), &signerKeySet{s.signer}, &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.ErrorContext(ctx, "error while fetching token claims", "err", err.Error()) return nil, newIntrospectInternalServerError() } clientID, err := getClientID(idToken.Audience, claims.AuthorizingParty) if err != nil { s.logger.ErrorContext(ctx, "error while fetching client_id from token:", "err", err.Error()) return nil, newIntrospectInternalServerError() } client, err := s.storage.GetClient(ctx, clientID) if err != nil { s.logger.ErrorContext(ctx, "error while fetching client from storage", "err", 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.ErrorContext(r.Context(), "unknown token type", "token_type", 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.ErrorContext(r.Context(), "an unknown error occurred", "err", err.Error()) s.introspectErrHelper(w, errServerError, "An unknown error occurred", http.StatusInternalServerError) } return } rawJSON, jsonErr := json.Marshal(introspect) if jsonErr != nil { s.logger.ErrorContext(r.Context(), "failed to marshal introspection response", "err", jsonErr) s.introspectErrHelper(w, errServerError, "", http.StatusInternalServerError) return } 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 { // TODO(nabokihms): error with context s.logger.Error("introspect error response", "err", 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}) }