mirror of https://github.com/dexidp/dex.git
8 changed files with 923 additions and 24 deletions
@ -0,0 +1,347 @@
|
||||
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(401) |
||||
json.NewEncoder(w).Encode(struct { |
||||
Active bool `json:"active"` |
||||
}{Active: false}) |
||||
} |
||||
@ -0,0 +1,415 @@
|
||||
package server |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"io" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"net/url" |
||||
"path" |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/dexidp/dex/server/internal" |
||||
"github.com/dexidp/dex/storage" |
||||
) |
||||
|
||||
func toJSON(a interface{}) string { |
||||
b, err := json.Marshal(a) |
||||
if err != nil { |
||||
return "" |
||||
} |
||||
|
||||
return string(b) |
||||
} |
||||
|
||||
func mockTestStorage(t *testing.T, s storage.Storage) { |
||||
ctx := context.Background() |
||||
c := storage.Client{ |
||||
ID: "test", |
||||
Secret: "barfoo", |
||||
RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"}, |
||||
Name: "dex client", |
||||
LogoURL: "https://goo.gl/JIyzIC", |
||||
} |
||||
|
||||
err := s.CreateClient(ctx, c) |
||||
require.NoError(t, err) |
||||
|
||||
c1 := storage.Connector{ |
||||
ID: "test", |
||||
Type: "mockPassword", |
||||
Name: "mockPassword", |
||||
Config: []byte(`{ |
||||
"username": "test", |
||||
"password": "test" |
||||
}`), |
||||
} |
||||
|
||||
err = s.CreateConnector(ctx, c1) |
||||
require.NoError(t, err) |
||||
|
||||
err = s.CreateRefresh(ctx, storage.RefreshToken{ |
||||
ID: "test", |
||||
Token: "bar", |
||||
ObsoleteToken: "", |
||||
Nonce: "foo", |
||||
ClientID: "test", |
||||
ConnectorID: "test", |
||||
Scopes: []string{"openid", "email", "profile"}, |
||||
CreatedAt: time.Now().UTC().Round(time.Millisecond), |
||||
LastUsed: time.Now().UTC().Round(time.Millisecond), |
||||
Claims: storage.Claims{ |
||||
UserID: "1", |
||||
Username: "jane", |
||||
Email: "jane.doe@example.com", |
||||
EmailVerified: true, |
||||
Groups: []string{"a", "b"}, |
||||
}, |
||||
ConnectorData: []byte(`{"some":"data"}`), |
||||
}) |
||||
require.NoError(t, err) |
||||
|
||||
err = s.CreateRefresh(ctx, storage.RefreshToken{ |
||||
ID: "expired", |
||||
Token: "bar", |
||||
ObsoleteToken: "", |
||||
Nonce: "foo", |
||||
ClientID: "test", |
||||
ConnectorID: "test", |
||||
Scopes: []string{"openid", "email", "profile"}, |
||||
CreatedAt: time.Now().AddDate(-1, 0, 0).UTC().Round(time.Millisecond), |
||||
LastUsed: time.Now().AddDate(-1, 0, 0).UTC().Round(time.Millisecond), |
||||
Claims: storage.Claims{ |
||||
UserID: "1", |
||||
Username: "jane", |
||||
Email: "jane.doe@example.com", |
||||
EmailVerified: true, |
||||
Groups: []string{"a", "b"}, |
||||
}, |
||||
ConnectorData: []byte(`{"some":"data"}`), |
||||
}) |
||||
require.NoError(t, err) |
||||
|
||||
err = s.CreateOfflineSessions(ctx, storage.OfflineSessions{ |
||||
UserID: "1", |
||||
ConnID: "test", |
||||
Refresh: map[string]*storage.RefreshTokenRef{ |
||||
"test": {ID: "test", ClientID: "test"}, |
||||
"expired": {ID: "expired", ClientID: "test"}, |
||||
}, |
||||
ConnectorData: nil, |
||||
}) |
||||
require.NoError(t, err) |
||||
} |
||||
|
||||
func getIntrospectionValue(issuerURL url.URL, issuedAt time.Time, expiry time.Time, tokenUse string) *Introspection { |
||||
trueValue := true |
||||
return &Introspection{ |
||||
Active: true, |
||||
ClientID: "test", |
||||
Subject: "CgExEgR0ZXN0", |
||||
Expiry: expiry.Unix(), |
||||
IssuedAt: issuedAt.Unix(), |
||||
NotBefore: issuedAt.Unix(), |
||||
Audience: []string{ |
||||
"test", |
||||
}, |
||||
Issuer: issuerURL.String(), |
||||
TokenType: "Bearer", |
||||
TokenUse: tokenUse, |
||||
Extra: IntrospectionExtra{ |
||||
Email: "jane.doe@example.com", |
||||
EmailVerified: &trueValue, |
||||
Groups: []string{ |
||||
"a", |
||||
"b", |
||||
}, |
||||
Name: "jane", |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func TestGetTokenFromRequestSuccess(t *testing.T) { |
||||
t0 := time.Now() |
||||
|
||||
now := func() time.Time { return t0 } |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Setup a dex server.
|
||||
httpServer, s := newTestServer(ctx, t, func(c *Config) { |
||||
c.Issuer += "/non-root-path" |
||||
c.Now = now |
||||
}) |
||||
defer httpServer.Close() |
||||
|
||||
tests := []struct { |
||||
testName string |
||||
expectedToken string |
||||
expectedTokenType TokenTypeEnum |
||||
}{ |
||||
// Access Token
|
||||
{ |
||||
testName: "Access Token", |
||||
expectedToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", |
||||
expectedTokenType: AccessToken, |
||||
}, |
||||
// Refresh Token
|
||||
{ |
||||
testName: "Refresh token", |
||||
expectedToken: "CgR0ZXN0EgNiYXI", |
||||
expectedTokenType: RefreshToken, |
||||
}, |
||||
// Unknown token
|
||||
{ |
||||
testName: "Unknown token", |
||||
expectedToken: "AaAaAaA", |
||||
expectedTokenType: RefreshToken, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range tests { |
||||
t.Run(tc.testName, func(t *testing.T) { |
||||
data := url.Values{} |
||||
data.Set("token", tc.expectedToken) |
||||
req := httptest.NewRequest(http.MethodPost, "https://test.tech/token/introspect", bytes.NewBufferString(data.Encode())) |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
||||
token, tokenType, err := s.getTokenFromRequest(req) |
||||
if err != nil { |
||||
t.Fatalf("Error returned: %s", err.Error()) |
||||
} |
||||
|
||||
if token != tc.expectedToken { |
||||
t.Fatalf("Wrong token returned. Expected %v got %v", tc.expectedToken, token) |
||||
} |
||||
|
||||
if tokenType != tc.expectedTokenType { |
||||
t.Fatalf("Wrong token type returned. Expected %v got %v", tc.expectedTokenType, tokenType) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestGetTokenFromRequestFailure(t *testing.T) { |
||||
t0 := time.Now() |
||||
|
||||
now := func() time.Time { return t0 } |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Setup a dex server.
|
||||
httpServer, s := newTestServer(ctx, t, func(c *Config) { |
||||
c.Issuer += "/non-root-path" |
||||
c.Now = now |
||||
}) |
||||
defer httpServer.Close() |
||||
|
||||
_, _, err := s.getTokenFromRequest(httptest.NewRequest(http.MethodGet, "https://test.tech/token/introspect", nil)) |
||||
require.ErrorIs(t, err, &introspectionError{ |
||||
typ: errInvalidRequest, |
||||
desc: "HTTP method is \"GET\", expected \"POST\".", |
||||
code: http.StatusBadRequest, |
||||
}) |
||||
|
||||
_, _, err = s.getTokenFromRequest(httptest.NewRequest(http.MethodPost, "https://test.tech/token/introspect", nil)) |
||||
require.ErrorIs(t, err, &introspectionError{ |
||||
typ: errInvalidRequest, |
||||
desc: "The POST body can not be empty.", |
||||
code: http.StatusBadRequest, |
||||
}) |
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "https://test.tech/token/introspect", strings.NewReader("token_type_hint=access_token")) |
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") |
||||
_, _, err = s.getTokenFromRequest(req) |
||||
require.ErrorIs(t, err, &introspectionError{ |
||||
typ: errInvalidRequest, |
||||
desc: "The POST body doesn't contain 'token' parameter.", |
||||
code: http.StatusBadRequest, |
||||
}) |
||||
} |
||||
|
||||
func TestHandleIntrospect(t *testing.T) { |
||||
t0 := time.Now() |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Setup a dex server.
|
||||
now := func() time.Time { return t0 } |
||||
|
||||
refreshTokenPolicy, err := NewRefreshTokenPolicy(logger, false, "", "24h", "") |
||||
if err != nil { |
||||
t.Fatalf("failed to prepare rotation policy: %v", err) |
||||
} |
||||
refreshTokenPolicy.now = now |
||||
|
||||
httpServer, s := newTestServer(ctx, t, func(c *Config) { |
||||
c.Issuer += "/non-root-path" |
||||
c.RefreshTokenPolicy = refreshTokenPolicy |
||||
c.Now = now |
||||
}) |
||||
defer httpServer.Close() |
||||
|
||||
mockTestStorage(t, s.storage) |
||||
|
||||
activeAccessToken, expiry, err := s.newIDToken("test", storage.Claims{ |
||||
UserID: "1", |
||||
Username: "jane", |
||||
Email: "jane.doe@example.com", |
||||
EmailVerified: true, |
||||
Groups: []string{"a", "b"}, |
||||
}, []string{"openid", "email", "profile", "groups"}, "foo", "", "", "test") |
||||
require.NoError(t, err) |
||||
|
||||
activeRefreshToken, err := internal.Marshal(&internal.RefreshToken{RefreshId: "test", Token: "bar"}) |
||||
require.NoError(t, err) |
||||
expiredRefreshToken, err := internal.Marshal(&internal.RefreshToken{RefreshId: "expired", Token: "bar"}) |
||||
require.NoError(t, err) |
||||
|
||||
inactiveResponse := "{\"active\":false}\n" |
||||
badRequestResponse := `{"error":"invalid_request","error_description":"The POST body can not be empty."}` |
||||
|
||||
tests := []struct { |
||||
testName string |
||||
token string |
||||
tokenType string |
||||
response string |
||||
responseStatusCode int |
||||
}{ |
||||
// No token
|
||||
{ |
||||
testName: "No token", |
||||
response: badRequestResponse, |
||||
responseStatusCode: 400, |
||||
}, |
||||
// Access token tests
|
||||
{ |
||||
testName: "Access Token: active", |
||||
token: activeAccessToken, |
||||
response: toJSON(getIntrospectionValue(s.issuerURL, time.Now(), expiry, "access_token")), |
||||
responseStatusCode: 200, |
||||
}, |
||||
{ |
||||
testName: "Access Token: wrong", |
||||
token: "fake-token", |
||||
response: inactiveResponse, |
||||
responseStatusCode: 401, |
||||
}, |
||||
// Refresh token tests
|
||||
{ |
||||
testName: "Refresh Token: active", |
||||
token: activeRefreshToken, |
||||
response: toJSON(getIntrospectionValue(s.issuerURL, time.Now(), time.Now().Add(s.refreshTokenPolicy.absoluteLifetime), "refresh_token")), |
||||
responseStatusCode: 200, |
||||
}, |
||||
{ |
||||
testName: "Refresh Token: expired", |
||||
token: expiredRefreshToken, |
||||
response: inactiveResponse, |
||||
responseStatusCode: 401, |
||||
}, |
||||
{ |
||||
testName: "Refresh Token: active => false (wrong)", |
||||
token: "fake-token", |
||||
response: inactiveResponse, |
||||
responseStatusCode: 401, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range tests { |
||||
t.Run(tc.testName, func(t *testing.T) { |
||||
data := url.Values{} |
||||
if tc.token != "" { |
||||
data.Set("token", tc.token) |
||||
} |
||||
if tc.tokenType != "" { |
||||
data.Set("token_type_hint", tc.tokenType) |
||||
} |
||||
|
||||
u, err := url.Parse(s.issuerURL.String()) |
||||
if err != nil { |
||||
t.Fatalf("Could not parse issuer URL %v", err) |
||||
} |
||||
u.Path = path.Join(u.Path, "token", "introspect") |
||||
|
||||
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) |
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
||||
rr := httptest.NewRecorder() |
||||
s.ServeHTTP(rr, req) |
||||
|
||||
if rr.Code != tc.responseStatusCode { |
||||
t.Errorf("%s: Unexpected Response Type. Expected %v got %v", tc.testName, tc.responseStatusCode, rr.Code) |
||||
} |
||||
|
||||
result, _ := io.ReadAll(rr.Body) |
||||
if string(result) != tc.response { |
||||
t.Errorf("%s: Unexpected Response. Expected %q got %q", tc.testName, tc.response, result) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestIntrospectErrHelper(t *testing.T) { |
||||
t0 := time.Now() |
||||
|
||||
now := func() time.Time { return t0 } |
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
defer cancel() |
||||
|
||||
// Setup a dex server.
|
||||
httpServer, s := newTestServer(ctx, t, func(c *Config) { |
||||
c.Issuer += "/non-root-path" |
||||
c.Now = now |
||||
}) |
||||
defer httpServer.Close() |
||||
|
||||
tests := []struct { |
||||
testName string |
||||
err *introspectionError |
||||
resStatusCode int |
||||
resBody string |
||||
}{ |
||||
{ |
||||
testName: "Inactive Token", |
||||
err: newIntrospectInactiveTokenError(), |
||||
resStatusCode: http.StatusUnauthorized, |
||||
resBody: "{\"active\":false}\n", |
||||
}, |
||||
{ |
||||
testName: "Bad Request", |
||||
err: newIntrospectBadRequestError("This is a bad request"), |
||||
resStatusCode: http.StatusBadRequest, |
||||
resBody: `{"error":"invalid_request","error_description":"This is a bad request"}`, |
||||
}, |
||||
{ |
||||
testName: "Internal Server Error", |
||||
err: newIntrospectInternalServerError(), |
||||
resStatusCode: http.StatusInternalServerError, |
||||
resBody: `{"error":"server_error"}`, |
||||
}, |
||||
} |
||||
for _, tc := range tests { |
||||
t.Run(tc.testName, func(t *testing.T) { |
||||
w1 := httptest.NewRecorder() |
||||
|
||||
s.introspectErrHelper(w1, tc.err.typ, tc.err.desc, tc.err.code) |
||||
|
||||
res := w1.Result() |
||||
require.Equal(t, tc.resStatusCode, res.StatusCode) |
||||
require.Equal(t, "application/json", res.Header.Get("Content-Type")) |
||||
|
||||
data, err := io.ReadAll(res.Body) |
||||
defer res.Body.Close() |
||||
require.NoError(t, err) |
||||
require.Equal(t, tc.resBody, string(data)) |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue