mirror of https://github.com/dexidp/dex.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
348 lines
11 KiB
348 lines
11 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 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(), &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.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.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 { |
|
// 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}) |
|
}
|
|
|