-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[RFC7662] Add introspect endpoint to introspect access & refresh toke…
…n. See issue #3387 Signed-off-by: Romain Caire <[email protected]>
- Loading branch information
1 parent
8652a7c
commit 4e1031f
Showing
7 changed files
with
795 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
|
||
// Extra is arbitrary data set from the token claims. | ||
Extra IntrospectionExtra `json:"ext,omitempty"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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) | ||
} | ||
} | ||
} | ||
|
||
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) | ||
return nil | ||
} |
Oops, something went wrong.