diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index 6063c42b19..53d776fe94 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -102,6 +102,9 @@ type Config struct { // If not setted it will be auto-detected the best-fit for the connector. PKCEChallenge string `json:"pkceChallenge"` + // PKCEChallenge specifies which PKCE algorithm will be used + PKCEChallenge string `json:"pkceChallenge"` + // OverrideClaimMapping will be used to override the options defined in claimMappings. // i.e. if there are 'email' and `preferred_email` claims available, by default Dex will always use the `email` claim independent of the ClaimMapping.EmailKey. // This setting allows you to override the default behavior of Dex and enforce the mappings defined in `claimMapping`. @@ -396,6 +399,7 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, pkceChallenge: c.PKCEChallenge, + pkceChallenge: c.PKCEChallenge, endSessionURL: endSessionURL, }, nil } @@ -432,9 +436,21 @@ type oidcConnector struct { groupsPrefix string groupsSuffix string pkceChallenge string + pkceChallenge string endSessionURL string } +func getAuthCodeOptionForCodeChallenge(codeVerifier, codeChallengeMethod string) (oauth2.AuthCodeOption, error) { + switch codeChallengeMethod { + case "plain": + return oauth2.VerifierOption(codeVerifier), nil + case "S256": + return oauth2.S256ChallengeOption(codeVerifier), nil + default: + return nil, fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) + } +} + func (c *oidcConnector) Close() error { c.cancel() return nil @@ -457,6 +473,16 @@ func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType)) } + if c.pkceChallenge != "" { + codeVerifier := oauth2.GenerateVerifier() + authCodeOption, err := getAuthCodeOptionForCodeChallenge(codeVerifier, c.pkceChallenge) + if err != nil { + return "", fmt.Errorf("oidc: failed to get PKCE AuthCodeOption for CodeChallenge: %v", err) + } + opts = append(opts, authCodeOption) + state = state + ":pkce:" + codeVerifier + } + if c.pkceChallenge != "" { codeVerifier := oauth2.GenerateVerifier() authCodeOption, err := getAuthCodeOptionForCodeChallenge(codeVerifier, c.pkceChallenge) diff --git a/connector/oidc/oidc.go.orig b/connector/oidc/oidc.go.orig new file mode 100644 index 0000000000..6063c42b19 --- /dev/null +++ b/connector/oidc/oidc.go.orig @@ -0,0 +1,807 @@ +// Package oidc implements logging in through OpenID Connect providers. +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/dexidp/dex/connector" + groups_pkg "github.com/dexidp/dex/pkg/groups" + "github.com/dexidp/dex/pkg/httpclient" +) + +const ( + codeChallengeMethodPlain = "plain" + codeChallengeMethodS256 = "S256" +) + +func contains(arr []string, item string) bool { + for _, itemFromArray := range arr { + if itemFromArray == item { + return true + } + } + return false +} + +// Config holds configuration options for OpenID Connect logins. +type Config struct { + Issuer string `json:"issuer"` + // Some offspec providers like Azure, Oracle IDCS have oidc discovery url + // different from issuer url which causes issuerValidation to fail + // IssuerAlias provides a way to override the Issuer url + // from the .well-known/openid-configuration issuer + IssuerAlias string `json:"issuerAlias"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + + // The section to override options discovered automatically from + // the providers' discovery URL (.well-known/openid-configuration). + ProviderDiscoveryOverrides ProviderDiscoveryOverrides `json:"providerDiscoveryOverrides"` + + // Causes client_secret to be passed as POST parameters instead of basic + // auth. This is specifically "NOT RECOMMENDED" by the OAuth2 RFC, but some + // providers require it. + // + // https://tools.ietf.org/html/rfc6749#section-2.3.1 + BasicAuthUnsupported *bool `json:"basicAuthUnsupported"` + + Scopes []string `json:"scopes"` // defaults to "profile" and "email" + + // HostedDomains was an optional list of whitelisted domains when using the OIDC connector with Google. + // Only users from a whitelisted domain were allowed to log in. + // Support for this option was removed from the OIDC connector. + // Consider switching to the Google connector which supports this option. + // + // Deprecated: will be removed in future releases. + HostedDomains []string `json:"hostedDomains"` + + // Certificates for SSL validation + RootCAs []string `json:"rootCAs"` + + // Override the value of email_verified to true in the returned claims + InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"` + + // InsecureEnableGroups enables groups claims. This is disabled by default until https://github.com/dexidp/dex/issues/1065 is resolved + InsecureEnableGroups bool `json:"insecureEnableGroups"` + AllowedGroups []string `json:"allowedGroups"` + + // AcrValues (Authentication Context Class Reference Values) that specifies the Authentication Context Class Values + // within the Authentication Request that the Authorization Server is being requested to use for + // processing requests from this Client, with the values appearing in order of preference. + AcrValues []string `json:"acrValues"` + + // Disable certificate verification + InsecureSkipVerify bool `json:"insecureSkipVerify"` + + // GetUserInfo uses the userinfo endpoint to get additional claims for + // the token. This is especially useful where upstreams return "thin" + // id tokens + GetUserInfo bool `json:"getUserInfo"` + + UserIDKey string `json:"userIDKey"` + + UserNameKey string `json:"userNameKey"` + + // PromptType will be used for the prompt parameter (when offline_access, by default prompt=consent) + PromptType *string `json:"promptType"` + + // PKCEChallenge specifies which PKCE algorithm will be used + // If not setted it will be auto-detected the best-fit for the connector. + PKCEChallenge string `json:"pkceChallenge"` + + // OverrideClaimMapping will be used to override the options defined in claimMappings. + // i.e. if there are 'email' and `preferred_email` claims available, by default Dex will always use the `email` claim independent of the ClaimMapping.EmailKey. + // This setting allows you to override the default behavior of Dex and enforce the mappings defined in `claimMapping`. + OverrideClaimMapping bool `json:"overrideClaimMapping"` // defaults to false + + ClaimMapping struct { + // Configurable key which contains the preferred username claims + PreferredUsernameKey string `json:"preferred_username"` // defaults to "preferred_username" + + // Configurable key which contains the email claims + EmailKey string `json:"email"` // defaults to "email" + + // Configurable key which contains the groups claims + GroupsKey string `json:"groups"` // defaults to "groups" + } `json:"claimMapping"` + + // ClaimMutations holds all claim mutations options + ClaimMutations struct { + NewGroupFromClaims []NewGroupFromClaims `json:"newGroupFromClaims"` + FilterGroupClaims FilterGroupClaims `json:"filterGroupClaims"` + ModifyGroupNames ModifyGroupNames `json:"modifyGroupNames"` + } `json:"claimModifications"` +} + +type ProviderDiscoveryOverrides struct { + // TokenURL provides a way to user overwrite the Token URL + // from the .well-known/openid-configuration token_endpoint + TokenURL string `json:"tokenURL"` + // AuthURL provides a way to user overwrite the Auth URL + // from the .well-known/openid-configuration authorization_endpoint + AuthURL string `json:"authURL"` + // JWKSURL provides a way to user overwrite the JWKS URL + // from the .well-known/openid-configuration jwks_uri + JWKSURL string `json:"jwksURL"` + // EndSessionURL provides a way to override the end_session_endpoint + // from the .well-known/openid-configuration + EndSessionURL string `json:"endSessionURL"` +} + +func (o *ProviderDiscoveryOverrides) Empty() bool { + return o.TokenURL == "" && o.AuthURL == "" && o.JWKSURL == "" && o.EndSessionURL == "" +} + +func getProvider(ctx context.Context, issuer string, overrides ProviderDiscoveryOverrides) (*oidc.Provider, error) { + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("failed to get provider: %v", err) + } + + if overrides.Empty() { + return provider, nil + } + + v := &struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + DeviceAuthURL string `json:"device_authorization_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + Algorithms []string `json:"id_token_signing_alg_values_supported"` + }{} + if err := provider.Claims(v); err != nil { + return nil, fmt.Errorf("failed to extract provider discovery claims: %v", err) + } + config := oidc.ProviderConfig{ + IssuerURL: v.Issuer, + AuthURL: v.AuthURL, + TokenURL: v.TokenURL, + DeviceAuthURL: v.DeviceAuthURL, + JWKSURL: v.JWKSURL, + UserInfoURL: v.UserInfoURL, + Algorithms: v.Algorithms, + } + + if overrides.TokenURL != "" { + config.TokenURL = overrides.TokenURL + } + if overrides.AuthURL != "" { + config.AuthURL = overrides.AuthURL + } + if overrides.JWKSURL != "" { + config.JWKSURL = overrides.JWKSURL + } + return config.NewProvider(context.Background()), nil +} + +// NewGroupFromClaims creates a new group from a list of claims and appends it to the list of existing groups. +type NewGroupFromClaims struct { + // List of claim to join together + Claims []string `json:"claims"` + + // String to separate the claims + Delimiter string `json:"delimiter"` + + // Should Dex remove the Delimiter string from claim values + // This is done to keep resulting claim structure in full control of the Dex operator + ClearDelimiter bool `json:"clearDelimiter"` + + // String to place before the first claim + Prefix string `json:"prefix"` +} + +// FilterGroupClaims is a regex filter for to keep only the matching groups. +// This is useful when the groups list is too large to fit within an HTTP header. +type FilterGroupClaims struct { + GroupsFilter string `json:"groupsFilter"` +} + +// ModifyGroupNames allows to modify the group claims by adding a prefix and/or suffix to each group. +type ModifyGroupNames struct { + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` +} + +// Domains that don't support basic auth. golang.org/x/oauth2 has an internal +// list, but it only matches specific URLs, not top level domains. +var brokenAuthHeaderDomains = []string{ + // See: https://github.com/dexidp/dex/issues/859 + "okta.com", + "oktapreview.com", +} + +// connectorData stores information for sessions authenticated by this connector +type connectorData struct { + RefreshToken []byte + IDToken []byte // raw upstream id_token JWT for RP-Initiated logout +} + +// Detect auth header provider issues for known providers. This lets users +// avoid having to explicitly set "basicAuthUnsupported" in their config. +// +// Setting the config field always overrides values returned by this function. +func knownBrokenAuthHeaderProvider(issuerURL string) bool { + if u, err := url.Parse(issuerURL); err == nil { + for _, host := range brokenAuthHeaderDomains { + if u.Host == host || strings.HasSuffix(u.Host, "."+host) { + return true + } + } + } + return false +} + +// PKCEChallengeData is used to store info for PKCE Challenge method and verifier +// in the connectorData +type PKCEChallengeData struct { + CodeChallenge string `json:"codeChallenge"` + CodeChallengeMethod string `json:"codeChallengeMethod"` +} + +// Returns an AuthCodeOption according to the provided codeChallengeMethod +func getAuthCodeOptionForCodeChallenge(codeVerifier, codeChallengeMethod string) (oauth2.AuthCodeOption, error) { + switch codeChallengeMethod { + case codeChallengeMethodPlain: + return oauth2.VerifierOption(codeVerifier), nil + case codeChallengeMethodS256: + return oauth2.S256ChallengeOption(codeVerifier), nil + default: + return nil, fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) + } +} + +// Open returns a connector which can be used to login users through an upstream +// OpenID Connect provider. +func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, err error) { + if len(c.HostedDomains) > 0 { + return nil, fmt.Errorf("support for the Hosted domains option had been deprecated and removed, consider switching to the Google connector") + } + + httpClient, err := httpclient.NewHTTPClient(c.RootCAs, c.InsecureSkipVerify) + if err != nil { + return nil, err + } + + bgctx, cancel := context.WithCancel(context.Background()) + ctx := context.WithValue(bgctx, oauth2.HTTPClient, httpClient) + if c.IssuerAlias != "" { + ctx = oidc.InsecureIssuerURLContext(ctx, c.IssuerAlias) + } + provider, err := getProvider(ctx, c.Issuer, c.ProviderDiscoveryOverrides) + if err != nil { + cancel() + return nil, err + } + if !c.ProviderDiscoveryOverrides.Empty() { + logger.Warn("overrides for connector are set, this can be a vulnerability when not properly configured", "connector_id", id) + } + + endpoint := provider.Endpoint() + + if c.BasicAuthUnsupported != nil { + // Setting "basicAuthUnsupported" always overrides our detection. + if *c.BasicAuthUnsupported { + endpoint.AuthStyle = oauth2.AuthStyleInParams + } + } else if knownBrokenAuthHeaderProvider(c.Issuer) { + endpoint.AuthStyle = oauth2.AuthStyleInParams + } + + scopes := []string{oidc.ScopeOpenID} + if len(c.Scopes) > 0 { + scopes = append(scopes, c.Scopes...) + } else { + scopes = append(scopes, "profile", "email") + } + + // PromptType should be "consent" by default, if not set + promptType := "consent" + if c.PromptType != nil { + promptType = *c.PromptType + } + + var groupsFilter *regexp.Regexp + if c.ClaimMutations.FilterGroupClaims.GroupsFilter != "" { + groupsFilter, err = regexp.Compile(c.ClaimMutations.FilterGroupClaims.GroupsFilter) + if err != nil { + logger.Warn("ignoring invalid", "invalid_regex", c.ClaimMutations.FilterGroupClaims.GroupsFilter, "connector_id", id) + } + } + + // Obtain metadata from the provider + var metadata struct { + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := provider.Claims(&metadata); err != nil { + logger.Warn("failed to parse provider metadata") + } + // if PKCEChallenge method has not been setted in the config, auto-detect the best fit + if c.PKCEChallenge == "" { + if contains(metadata.CodeChallengeMethodsSupported, codeChallengeMethodS256) { + c.PKCEChallenge = codeChallengeMethodS256 + } else if contains(metadata.CodeChallengeMethodsSupported, codeChallengeMethodPlain) { + c.PKCEChallenge = codeChallengeMethodPlain + } + } else { + // if PKCEChallenge method has been setted in the config, check if it is supported + if !contains(metadata.CodeChallengeMethodsSupported, c.PKCEChallenge) { + logger.Warn("provided PKCEChallenge method not supported by the connector") + } + } + + endSessionURL := metadata.EndSessionEndpoint + if c.ProviderDiscoveryOverrides.EndSessionURL != "" { + endSessionURL = c.ProviderDiscoveryOverrides.EndSessionURL + } + if endSessionURL != "" { + endSessionParsed, err := url.Parse(endSessionURL) + if err != nil { + cancel() + return nil, fmt.Errorf("oidc: invalid end_session_endpoint: %v", err) + } + if endSessionParsed.Scheme != "https" && endSessionParsed.Scheme != "http" { + cancel() + return nil, fmt.Errorf("oidc: end_session_endpoint must use http or https scheme, got %q", endSessionParsed.Scheme) + } + } + + clientID := c.ClientID + return &oidcConnector{ + provider: provider, + redirectURI: c.RedirectURI, + oauth2Config: &oauth2.Config{ + ClientID: clientID, + ClientSecret: c.ClientSecret, + Endpoint: endpoint, + Scopes: scopes, + RedirectURL: c.RedirectURI, + }, + verifier: provider.VerifierContext( + ctx, // Pass our ctx with customized http.Client + &oidc.Config{ClientID: clientID}, + ), + logger: logger.With(slog.Group("connector", "type", "oidc", "id", id)), + cancel: cancel, + httpClient: httpClient, + insecureSkipEmailVerified: c.InsecureSkipEmailVerified, + insecureEnableGroups: c.InsecureEnableGroups, + allowedGroups: c.AllowedGroups, + acrValues: c.AcrValues, + getUserInfo: c.GetUserInfo, + promptType: promptType, + userIDKey: c.UserIDKey, + userNameKey: c.UserNameKey, + overrideClaimMapping: c.OverrideClaimMapping, + preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey, + emailKey: c.ClaimMapping.EmailKey, + groupsKey: c.ClaimMapping.GroupsKey, + newGroupFromClaims: c.ClaimMutations.NewGroupFromClaims, + groupsFilter: groupsFilter, + groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, + groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, + pkceChallenge: c.PKCEChallenge, + endSessionURL: endSessionURL, + }, nil +} + +var ( + _ connector.CallbackConnector = (*oidcConnector)(nil) + _ connector.RefreshConnector = (*oidcConnector)(nil) + _ connector.TokenIdentityConnector = (*oidcConnector)(nil) + _ connector.LogoutCallbackConnector = (*oidcConnector)(nil) +) + +type oidcConnector struct { + provider *oidc.Provider + redirectURI string + oauth2Config *oauth2.Config + verifier *oidc.IDTokenVerifier + cancel context.CancelFunc + logger *slog.Logger + httpClient *http.Client + insecureSkipEmailVerified bool + insecureEnableGroups bool + allowedGroups []string + acrValues []string + getUserInfo bool + promptType string + userIDKey string + userNameKey string + overrideClaimMapping bool + preferredUsernameKey string + emailKey string + groupsKey string + newGroupFromClaims []NewGroupFromClaims + groupsFilter *regexp.Regexp + groupsPrefix string + groupsSuffix string + pkceChallenge string + endSessionURL string +} + +func (c *oidcConnector) Close() error { + c.cancel() + return nil +} + +func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, []byte, error) { + if c.redirectURI != callbackURL { + return "", nil, fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) + } + + var opts []oauth2.AuthCodeOption + var connectorData []byte + + if len(c.acrValues) > 0 { + acrValues := strings.Join(c.acrValues, " ") + opts = append(opts, oauth2.SetAuthURLParam("acr_values", acrValues)) + } + + if s.OfflineAccess { + opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType)) + } + + if c.pkceChallenge != "" { + codeVerifier := oauth2.GenerateVerifier() + authCodeOption, err := getAuthCodeOptionForCodeChallenge(codeVerifier, c.pkceChallenge) + if err != nil { + return "", nil, fmt.Errorf("oidc: failed to get PKCE AuthCodeOption for CodeChallenge: %v", err) + } + data := PKCEChallengeData{ + CodeChallenge: codeVerifier, + CodeChallengeMethod: c.pkceChallenge, + } + connectorData, err = json.Marshal(data) + if err != nil { + return "", nil, fmt.Errorf("oidc: failed to create PKCEChallenge data: %v", err) + } + opts = append(opts, authCodeOption) + } + + return c.oauth2Config.AuthCodeURL(state, opts...), connectorData, nil +} + +type oauth2Error struct { + error string + errorDescription string +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} + +type caller uint + +const ( + createCaller caller = iota + refreshCaller + exchangeCaller +) + +func (c *oidcConnector) HandleCallback(s connector.Scopes, connData []byte, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, &oauth2Error{errType, q.Get("error_description")} + } + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + + var opts []oauth2.AuthCodeOption + if c.pkceChallenge != "" { + var data PKCEChallengeData + if err := json.Unmarshal(connData, &data); err != nil { + return identity, fmt.Errorf("oidc: failed to parse PKCEChallenge data: %v", err) + } + if data.CodeChallenge == "" { + return identity, fmt.Errorf("oidc: invalid PKCE CodeChallenge") + } + opts = append(opts, oauth2.VerifierOption(data.CodeChallenge)) + } + + token, err := c.oauth2Config.Exchange(ctx, q.Get("code"), opts...) + if err != nil { + return identity, fmt.Errorf("oidc: failed to get token: %v", err) + } + return c.createIdentity(ctx, identity, token, createCaller) +} + +// Refresh is used to refresh a session with the refresh token provided by the IdP +func (c *oidcConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + cd := connectorData{} + err := json.Unmarshal(identity.ConnectorData, &cd) + if err != nil { + return identity, fmt.Errorf("oidc: failed to unmarshal connector data: %v", err) + } + + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + + t := &oauth2.Token{ + RefreshToken: string(cd.RefreshToken), + Expiry: time.Now().Add(-time.Hour), + } + token, err := c.oauth2Config.TokenSource(ctx, t).Token() + if err != nil { + return identity, fmt.Errorf("oidc: failed to get refresh token: %v", err) + } + return c.createIdentity(ctx, identity, token, refreshCaller) +} + +func (c *oidcConnector) TokenIdentity(ctx context.Context, subjectTokenType, subjectToken string) (connector.Identity, error) { + var identity connector.Identity + + ctx = context.WithValue(ctx, oauth2.HTTPClient, c.httpClient) + + token := &oauth2.Token{ + AccessToken: subjectToken, + TokenType: subjectTokenType, + } + return c.createIdentity(ctx, identity, token, exchangeCaller) +} + +func (c *oidcConnector) createIdentity(ctx context.Context, identity connector.Identity, token *oauth2.Token, caller caller) (connector.Identity, error) { + var claims map[string]interface{} + + if rawIDToken, ok := token.Extra("id_token").(string); ok { + idToken, err := c.verifier.Verify(ctx, rawIDToken) + if err != nil { + return identity, fmt.Errorf("oidc: failed to verify ID Token: %v", err) + } + + if err := idToken.Claims(&claims); err != nil { + return identity, fmt.Errorf("oidc: failed to decode claims: %v", err) + } + } else if caller == exchangeCaller { + switch token.TokenType { + case "urn:ietf:params:oauth:token-type:id_token": + // Verify only works on ID tokens + idToken, err := c.provider.Verifier(&oidc.Config{SkipClientIDCheck: true}).Verify(ctx, token.AccessToken) + if err != nil { + return identity, fmt.Errorf("oidc: failed to verify token: %v", err) + } + if err := idToken.Claims(&claims); err != nil { + return identity, fmt.Errorf("oidc: failed to decode claims: %v", err) + } + case "urn:ietf:params:oauth:token-type:access_token": + if !c.getUserInfo { + return identity, fmt.Errorf("oidc: getUserInfo is required for access token exchange") + } + default: + return identity, fmt.Errorf("unknown token type for token exchange: %s", token.TokenType) + } + } else if caller != refreshCaller { + // ID tokens aren't mandatory in the reply when using a refresh_token grant + return identity, errors.New("oidc: no id_token in token response") + } + + // We immediately want to run getUserInfo if configured before we validate the claims. + // For token exchanges with access tokens, this is how we verify the token. + if c.getUserInfo { + userInfo, err := c.provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: token.AccessToken, + TokenType: "Bearer", // The UserInfo endpoint requires a bearer token as per RFC6750 + })) + if err != nil { + return identity, fmt.Errorf("oidc: error loading userinfo: %v", err) + } + if err := userInfo.Claims(&claims); err != nil { + return identity, fmt.Errorf("oidc: failed to decode userinfo claims: %v", err) + } + } + + const subjectClaimKey = "sub" + subject, found := claims[subjectClaimKey].(string) + if !found { + return identity, fmt.Errorf("missing \"%s\" claim", subjectClaimKey) + } + + userNameKey := "name" + if c.userNameKey != "" { + userNameKey = c.userNameKey + } + name, found := claims[userNameKey].(string) + if !found { + return identity, fmt.Errorf("missing \"%s\" claim", userNameKey) + } + + preferredUsername, found := claims["preferred_username"].(string) + if (!found || c.overrideClaimMapping) && c.preferredUsernameKey != "" { + preferredUsername, _ = claims[c.preferredUsernameKey].(string) + } + + hasEmailScope := false + for _, s := range c.oauth2Config.Scopes { + if s == "email" { + hasEmailScope = true + break + } + } + + var email string + emailKey := "email" + email, found = claims[emailKey].(string) + if (!found || c.overrideClaimMapping) && c.emailKey != "" { + emailKey = c.emailKey + email, found = claims[emailKey].(string) + } + + if !found && hasEmailScope { + return identity, fmt.Errorf("missing email claim, not found \"%s\" key", emailKey) + } + + emailVerified, found := claims["email_verified"].(bool) + if !found { + if c.insecureSkipEmailVerified { + emailVerified = true + } else if hasEmailScope { + return identity, errors.New("missing \"email_verified\" claim") + } + } + + var groups []string + if c.insecureEnableGroups { + groupsKey := "groups" + vs, found := claims[groupsKey].([]interface{}) + if (!found || c.overrideClaimMapping) && c.groupsKey != "" { + groupsKey = c.groupsKey + vs, found = claims[groupsKey].([]interface{}) + } + + // Fallback when claims[groupsKey] is a string instead of an array of strings. + if g, b := claims[groupsKey].(string); b { + groups = []string{g} + } + + if found { + for _, v := range vs { + if s, ok := v.(string); ok { + if c.groupsFilter != nil && !c.groupsFilter.MatchString(s) { + continue + } + groups = append(groups, s) + } else if groupMap, ok := v.(map[string]interface{}); ok { + if s, ok := groupMap["name"].(string); ok { + if c.groupsFilter != nil && !c.groupsFilter.MatchString(s) { + continue + } + groups = append(groups, s) + } + } else { + return identity, fmt.Errorf("malformed \"%v\" claim", groupsKey) + } + } + } + + // Validate that the user is part of allowedGroups + if len(c.allowedGroups) > 0 { + groupMatches := groups_pkg.Filter(groups, c.allowedGroups) + + if len(groupMatches) == 0 { + // No group membership matches found, disallowing + return identity, fmt.Errorf("user not a member of allowed groups") + } + + groups = groupMatches + } + } + + // add prefix/suffix to groups + if c.groupsPrefix != "" || c.groupsSuffix != "" { + for i, group := range groups { + groups[i] = c.groupsPrefix + group + c.groupsSuffix + } + } + + for _, config := range c.newGroupFromClaims { + newGroupSegments := []string{ + config.Prefix, + } + for _, claimName := range config.Claims { + claimValue, ok := claims[claimName].(string) + if !ok { // Non string claim value are ignored, concatenating them doesn't really make any sense + continue + } + + if config.ClearDelimiter { + // Removing the delimiter string from the concatenated claim to ensure resulting claim structure + // is in full control of Dex operator + claimValue = strings.ReplaceAll(claimValue, config.Delimiter, "") + } + + newGroupSegments = append(newGroupSegments, claimValue) + } + + if len(newGroupSegments) > 1 { + groups = append(groups, strings.Join(newGroupSegments, config.Delimiter)) + } + } + + cd := connectorData{ + RefreshToken: []byte(token.RefreshToken), + } + if rawIDToken, ok := token.Extra("id_token").(string); ok { + cd.IDToken = []byte(rawIDToken) + } + + connData, err := json.Marshal(&cd) + if err != nil { + return identity, fmt.Errorf("oidc: failed to encode connector data: %v", err) + } + + identity = connector.Identity{ + UserID: subject, + Username: name, + PreferredUsername: preferredUsername, + Email: email, + EmailVerified: emailVerified, + Groups: groups, + ConnectorData: connData, + } + + if c.userIDKey != "" { + userID, found := claims[c.userIDKey].(string) + if !found { + return identity, fmt.Errorf("oidc: not found %v claim", c.userIDKey) + } + identity.UserID = userID + } + + return identity, nil +} + +// LogoutURL returns the upstream OIDC provider's end_session_endpoint URL. +// Per the OIDC RP-Initiated Logout spec, the post_logout_redirect_uri parameter +// tells the upstream where to redirect after logout. +func (c *oidcConnector) LogoutURL(_ context.Context, rawConnectorData []byte, postLogoutRedirectURI string) (string, error) { + if c.endSessionURL == "" { + return "", nil + } + + u, err := url.Parse(c.endSessionURL) + if err != nil { + return "", fmt.Errorf("oidc: failed to parse end_session_endpoint: %v", err) + } + + q := u.Query() + if postLogoutRedirectURI != "" { + q.Set("post_logout_redirect_uri", postLogoutRedirectURI) + q.Set("client_id", c.oauth2Config.ClientID) + } + // Per the RP-Initiated Logout spec, id_token_hint is independently valid + // of post_logout_redirect_uri — include it whenever we have one. + if len(rawConnectorData) > 0 { + var cd connectorData + if err := json.Unmarshal(rawConnectorData, &cd); err == nil { + if len(cd.IDToken) > 0 { + q.Set("id_token_hint", string(cd.IDToken)) + } + } + } + u.RawQuery = q.Encode() + + return u.String(), nil +} + +// HandleLogoutCallback is a no-op for OIDC. The end_session_endpoint simply +// redirects back without a structured response to validate. +func (c *oidcConnector) HandleLogoutCallback(_ context.Context, _ *http.Request) error { + return nil +} diff --git a/connector/oidc/oidc.go.rej b/connector/oidc/oidc.go.rej new file mode 100644 index 0000000000..63f2aef085 --- /dev/null +++ b/connector/oidc/oidc.go.rej @@ -0,0 +1,68 @@ +--- connector/oidc/oidc.go ++++ connector/oidc/oidc.go +@@ -319,6 +322,7 @@ func (c *Config) Open(id string, logger *slog.Logger) (conn connector.Connector, + groupsFilter: groupsFilter, + groupsPrefix: c.ClaimMutations.ModifyGroupNames.Prefix, + groupsSuffix: c.ClaimMutations.ModifyGroupNames.Suffix, ++ pkceChallenge: c.PKCEChallenge, + }, nil + } + +@@ -351,6 +355,7 @@ type oidcConnector struct { + groupsFilter *regexp.Regexp + groupsPrefix string + groupsSuffix string ++ pkceChallenge string + } + + func (c *oidcConnector) Close() error { +@@ -358,6 +363,17 @@ func (c *oidcConnector) Close() error { + return nil + } + ++func getAuthCodeOptionForCodeChallenge(codeVerifier, codeChallengeMethod string) (oauth2.AuthCodeOption, error) { ++ switch codeChallengeMethod { ++ case "plain": ++ return oauth2.VerifierOption(codeVerifier), nil ++ case "S256": ++ return oauth2.S256ChallengeOption(codeVerifier), nil ++ default: ++ return nil, fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) ++ } ++} ++ + func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) (string, error) { + if c.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI) +@@ -373,6 +389,17 @@ func (c *oidcConnector) LoginURL(s connector.Scopes, callbackURL, state string) + if s.OfflineAccess { + opts = append(opts, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", c.promptType)) + } ++ ++ if c.pkceChallenge != "" { ++ codeVerifier := oauth2.GenerateVerifier() ++ authCodeOption, err := getAuthCodeOptionForCodeChallenge(codeVerifier, c.pkceChallenge) ++ if err != nil { ++ return "", fmt.Errorf("oidc: failed to get PKCE AuthCodeOption for CodeChallenge: %v", err) ++ } ++ opts = append(opts, authCodeOption) ++ state = state + ":pkce:" + codeVerifier ++ } ++ + return c.oauth2Config.AuthCodeURL(state, opts...), nil + } + +@@ -404,7 +431,12 @@ func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (ide + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) + +- token, err := c.oauth2Config.Exchange(ctx, q.Get("code")) ++ var opts []oauth2.AuthCodeOption ++ if verifier := q.Get("pkce_verifier"); verifier != "" { ++ opts = append(opts, oauth2.VerifierOption(verifier)) ++ } ++ ++ token, err := c.oauth2Config.Exchange(ctx, q.Get("code"), opts...) + if err != nil { + return identity, fmt.Errorf("oidc: failed to get token: %v", err) + } diff --git a/server/handlers.go b/server/handlers.go index d2412c9825..2f280a83d6 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -668,6 +668,13 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) s.renderError(r, w, http.StatusBadRequest, "User session error.") return } + // HACK for PKCE support: split state if it contains pkce verifier + if parts := strings.Split(authID, ":pkce:"); len(parts) == 2 { + authID = parts[0] + q := r.URL.Query() + q.Set("pkce_verifier", parts[1]) + r.URL.RawQuery = q.Encode() + } case http.MethodPost: // SAML POST binding if authID = r.PostFormValue("RelayState"); authID == "" { s.renderError(r, w, http.StatusBadRequest, "User session error.") diff --git a/server/handlers.go.orig b/server/handlers.go.orig new file mode 100644 index 0000000000..d2412c9825 --- /dev/null +++ b/server/handlers.go.orig @@ -0,0 +1,2014 @@ +package server + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "maps" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" + "github.com/gorilla/mux" + + "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/pkg/featureflags" + "github.com/dexidp/dex/server/internal" + "github.com/dexidp/dex/storage" +) + +const ( + codeChallengeMethodPlain = "plain" + codeChallengeMethodS256 = "S256" +) + +func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // TODO(ericchiang): Cache this. + keys, err := s.signer.ValidationKeys(ctx) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get keys", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + if len(keys) == 0 { + s.logger.ErrorContext(r.Context(), "no public keys found.") + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + jwks := jose.JSONWebKeySet{ + Keys: make([]jose.JSONWebKey, len(keys)), + } + for i, key := range keys { + jwks.Keys[i] = *key + } + + data, err := json.MarshalIndent(jwks, "", " ") + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to marshal discovery data", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + // We don't have NextRotation info from Signer interface easily, + // so we'll just set a reasonable default cache time. + maxAge := time.Minute * 10 + + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, must-revalidate", int(maxAge.Seconds()))) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Write(data) +} + +type discovery struct { + Issuer string `json:"issuer"` + Auth string `json:"authorization_endpoint"` + Token string `json:"token_endpoint"` + Keys string `json:"jwks_uri"` + UserInfo string `json:"userinfo_endpoint"` + DeviceEndpoint string `json:"device_authorization_endpoint"` + Introspect string `json:"introspection_endpoint"` + EndSession string `json:"end_session_endpoint,omitempty"` + GrantTypes []string `json:"grant_types_supported"` + ResponseTypes []string `json:"response_types_supported"` + Subjects []string `json:"subject_types_supported"` + IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` + CodeChallengeAlgs []string `json:"code_challenge_methods_supported"` + Scopes []string `json:"scopes_supported"` + AuthMethods []string `json:"token_endpoint_auth_methods_supported"` + Claims []string `json:"claims_supported"` +} + +func (s *Server) discoveryHandler(ctx context.Context) (http.HandlerFunc, error) { + d := s.constructDiscovery(ctx) + + data, err := json.MarshalIndent(d, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal discovery data: %v", err) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + w.Write(data) + }), nil +} + +func (s *Server) constructDiscovery(ctx context.Context) discovery { + d := discovery{ + Issuer: s.issuerURL.String(), + Auth: s.absURL("/auth"), + Token: s.absURL("/token"), + Keys: s.absURL("/keys"), + UserInfo: s.absURL("/userinfo"), + DeviceEndpoint: s.absURL("/device/code"), + Introspect: s.absURL("/token/introspect"), + Subjects: []string{"public"}, + IDTokenAlgs: []string{string(jose.RS256)}, + CodeChallengeAlgs: s.pkce.CodeChallengeMethodsSupported, + Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, + AuthMethods: []string{"client_secret_basic", "client_secret_post"}, + Claims: []string{ + "iss", "sub", "aud", "iat", "exp", "email", "email_verified", + "locale", "name", "preferred_username", "at_hash", + }, + } + + // Determine signing algorithm from signer + signingAlg, err := s.signer.Algorithm(ctx) + if err != nil { + s.logger.Error("failed to get signing algorithm", "err", err) + } else { + d.IDTokenAlgs = []string{string(signingAlg)} + } + + for responseType := range s.supportedResponseTypes { + d.ResponseTypes = append(d.ResponseTypes, responseType) + } + sort.Strings(d.ResponseTypes) + + d.GrantTypes = s.supportedGrantTypes + + if s.sessionConfig != nil { + d.EndSession = s.absURL("/logout") + } + + return d +} + +// grantTypeFromAuthRequest determines the grant type from the authorization request parameters. +func (s *Server) grantTypeFromAuthRequest(r *http.Request) string { + redirectURI := r.Form.Get("redirect_uri") + if redirectURI == deviceCallbackURI || strings.HasSuffix(redirectURI, deviceCallbackURI) { + return grantTypeDeviceCode + } + responseType := r.Form.Get("response_type") + for _, rt := range strings.Fields(responseType) { + if rt == "token" || rt == "id_token" { + return grantTypeImplicit + } + } + return grantTypeAuthorizationCode +} + +// handleAuthorization handles the OAuth2 auth endpoint. +func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // Extract the arguments + if err := r.ParseForm(); err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse arguments", "err", err) + + s.renderError(r, w, http.StatusBadRequest, ErrMsgInvalidRequest) + return + } + + connectorID := r.Form.Get("connector_id") + allConnectors, err := s.storage.ListConnectors(ctx) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get list of connectors", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve connector list.") + return + } + + // Determine the grant type from the authorization request to filter connectors. + grantType := s.grantTypeFromAuthRequest(r) + connectors := make([]storage.Connector, 0, len(allConnectors)) + for _, c := range allConnectors { + if GrantTypeAllowed(c.GrantTypes, grantType) { + connectors = append(connectors, c) + } + } + + // Filter connectors based on the client's allowed connectors list. + // client_id is required per RFC 6749 §4.1.1. + client, authErr := s.getClientWithAuthError(ctx, r.Form.Get("client_id")) + if authErr != nil { + s.renderError(r, w, authErr.Status, authErr.Error()) + return + } + connectors = filterConnectors(connectors, client.AllowedConnectors) + + if len(connectors) == 0 { + s.renderError(r, w, http.StatusBadRequest, "No connectors available for this client.") + return + } + + // We don't need connector_id any more + r.Form.Del("connector_id") + + // Construct a URL with all of the arguments in its query + connURL := url.URL{ + RawQuery: r.Form.Encode(), + } + + // Redirect if a client chooses a specific connector_id + if connectorID != "" { + for _, c := range connectors { + if c.ID == connectorID { + connURL.Path = s.absPath("/auth", url.PathEscape(c.ID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + } + s.renderError(r, w, http.StatusBadRequest, "Connector ID does not match a valid Connector") + return + } + + if len(connectors) == 1 && !s.alwaysShowLogin { + connURL.Path = s.absPath("/auth", url.PathEscape(connectors[0].ID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + + // Skip connector selection if a valid session exists, unless prompt=select_account or alwaysShowLogin. + if s.sessionConfig != nil { + authReq, _, err := s.parseAuthorizationRequest(r) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err) + + switch authErr := err.(type) { + case *redirectedAuthErr: + authErr.Handler().ServeHTTP(w, r) + case *displayedAuthErr: + s.renderError(r, w, authErr.Status, err.Error()) + default: + panic("unsupported error type") + } + return + } + prompt, err := ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + s.redirectWithError(w, r, authReq, errServerError, "Invalid authentication request") + return + } + + // Invalid prompts will be validated and properly redirected later + if !s.alwaysShowLogin && !prompt.SelectAccount() { + session := s.getValidSession(ctx, w, r) + if session != nil { + for _, c := range connectors { + if c.ID != session.ConnectorID { + continue + } + connURL.Path = s.absPath("/auth", url.PathEscape(session.ConnectorID)) + http.Redirect(w, r, connURL.String(), http.StatusFound) + return + } + } + } + if prompt.None() { + // Cannot authenticate silently with prompt=none. + s.redirectWithError(w, r, authReq, errLoginRequired, "id_token_hint does not match authenticated user") + return + } + } + + connectorInfos := make([]connectorInfo, 0, len(connectors)) + for _, conn := range connectors { + connURL.Path = s.absPath("/auth", url.PathEscape(conn.ID)) + connectorInfos = append(connectorInfos, connectorInfo{ + ID: conn.ID, + Name: conn.Name, + Type: conn.Type, + URL: template.URL(connURL.String()), + }) + } + + if err := s.templates.login(r, w, connectorInfos); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +// filterConnectors filters the list of connectors by the allowed connector IDs. +// If allowedConnectors is empty, all connectors are returned (no filtering). +func filterConnectors(connectors []storage.Connector, allowedConnectors []string) []storage.Connector { + if len(allowedConnectors) == 0 { + return connectors + } + + allowed := make(map[string]bool, len(allowedConnectors)) + for _, id := range allowedConnectors { + allowed[id] = true + } + + filtered := make([]storage.Connector, 0, len(connectors)) + for _, c := range connectors { + if allowed[c.ID] { + filtered = append(filtered, c) + } + } + return filtered +} + +// isConnectorAllowed checks if a connector ID is in the client's allowed connectors list. +// If allowedConnectors is empty, all connectors are allowed. +func isConnectorAllowed(allowedConnectors []string, connectorID string) bool { + if len(allowedConnectors) == 0 { + return true + } + for _, id := range allowedConnectors { + if id == connectorID { + return true + } + } + return false +} + +// getClientWithAuthError retrieves a client by ID and returns a displayedAuthErr on failure. +// Invalid client_id is not treated as a redirect error per RFC 6749 §4.1.2.1. +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 +func (s *Server) getClientWithAuthError(ctx context.Context, clientID string) (storage.Client, *displayedAuthErr) { + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + if err == storage.ErrNotFound { + s.logger.ErrorContext(ctx, "invalid client_id provided", "client_id", clientID) + return storage.Client{}, newDisplayedErr(http.StatusBadRequest, "Invalid client_id provided.") + } + s.logger.ErrorContext(ctx, "failed to get client", "client_id", clientID, "err", err) + return storage.Client{}, newDisplayedErr(http.StatusInternalServerError, "Database error.") + } + return client, nil +} + +func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + authReq, hintSubject, err := s.parseAuthorizationRequest(r) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse authorization request", "err", err) + + switch authErr := err.(type) { + case *redirectedAuthErr: + authErr.Handler().ServeHTTP(w, r) + case *displayedAuthErr: + s.renderError(r, w, authErr.Status, err.Error()) + default: + panic("unsupported error type") + } + + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse connector", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") + return + } + + // Validate that the connector is allowed for this client. + client, authErr := s.getClientWithAuthError(ctx, authReq.ClientID) + if authErr != nil { + s.renderError(r, w, authErr.Status, authErr.Error()) + return + } + if !isConnectorAllowed(client.AllowedConnectors, connID) { + s.logger.ErrorContext(r.Context(), "connector not allowed for client", + "connector_id", connID, "client_id", authReq.ClientID) + s.renderError(r, w, http.StatusForbidden, "Connector not allowed for this client.") + return + } + + conn, err := s.getConnector(ctx, connID) + if err != nil { + s.logger.ErrorContext(r.Context(), "Failed to get connector", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Connector failed to initialize") + return + } + + // Check if the connector allows the requested grant type. + grantType := s.grantTypeFromAuthRequest(r) + if !GrantTypeAllowed(conn.GrantTypes, grantType) { + s.logger.ErrorContext(r.Context(), "connector does not allow requested grant type", + "connector_id", connID, "grant_type", grantType) + s.renderError(r, w, http.StatusBadRequest, "Requested connector does not support this grant type.") + return + } + + // Set the connector being used for the login. + if authReq.ConnectorID != "" && authReq.ConnectorID != connID { + s.logger.ErrorContext(r.Context(), "mismatched connector ID in auth request", + "auth_request_connector_id", authReq.ConnectorID, "connector_id", connID) + s.renderError(r, w, http.StatusBadRequest, "Bad connector ID") + return + } + + authReq.ConnectorID = connID + + // Actually create the auth request + authReq.Expiry = s.now().Add(s.authRequestsValidFor) + if err := s.storage.CreateAuthRequest(ctx, *authReq); err != nil { + s.logger.ErrorContext(r.Context(), "failed to create authorization request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Failed to connect to the database.") + return + } + + // Handle OIDC prompt parameter and session-based login. + prompt, err := ParsePrompt(authReq.Prompt) + if err != nil { + // Server error because authReq was validated before saving it to database. + s.redirectWithError(w, r, authReq, errServerError, "Invalid authentication request") + return + } + // handle prompt only if sessions are enabled + if s.sessionConfig != nil { + // Retrieve the session once for use in both hint and prompt logic. + session := s.getValidAuthSession(ctx, w, r, authReq) + + // id_token_hint logic (OIDC Core 1.0 3.1.2.1): + // When a hint is provided, verify that the session user matches. + if hintSubject != "" { + if !sessionMatchesHint(session, hintSubject) { + // Clear the session if the user is different from the hint. + session = nil + } + if session == nil && prompt.None() { + // Cannot authenticate silently with prompt=none. + s.redirectWithError(w, r, authReq, errLoginRequired, "id_token_hint does not match authenticated user") + return + } + } + + // prompt=none: no UI allowed. + if prompt.None() { + redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, authReq, session) + if !ok { + s.redirectWithError(w, r, authReq, errLoginRequired, "User not authenticated") + return + } + if redirectURL != "" { + // Session found but user interaction is needed (consent or MFA) — no UI allowed. + s.redirectWithError(w, r, authReq, errInteractionRequired, "User interaction required") + return + } + return + } + + if !prompt.Login() { + // Normal flow: try session-based login (skip if prompt=login forces re-auth). + if redirectURL, ok := s.trySessionLoginWithSession(ctx, r, w, authReq, session); ok { + if redirectURL != "" { + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + } + return + } + } + } + + scopes := parseScopes(authReq.Scopes) + + // Work out where the "Select another login method" link should go. + // Include prompt=select_account so that handleAuthorization skips + // session-based connector reuse and shows the connector list. + backLink := "" + if len(s.connectors) > 1 { + backLinkParams := make(url.Values) + maps.Copy(backLinkParams, r.Form) + if s.sessionConfig != nil { + backLinkParams.Set("prompt", "select_account") + } + backLinkURL := url.URL{ + Path: s.absPath("/auth"), + RawQuery: backLinkParams.Encode(), + } + backLink = backLinkURL.String() + } + + switch r.Method { + case http.MethodGet: + switch conn := conn.Connector.(type) { + case connector.CallbackConnector: + // Use the auth request ID as the "state" token. + // + // TODO(ericchiang): Is this appropriate or should we also be using a nonce? + callbackURL, connData, err := conn.LoginURL(scopes, s.absURL("/callback"), authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "connector returned error when creating callback", "connector_id", connID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + if len(connData) > 0 { + updater := func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.ConnectorData = connData + return a, nil + } + err := s.storage.UpdateAuthRequest(ctx, authReq.ID, updater) + if err != nil { + s.logger.ErrorContext(r.Context(), "Failed to set connector data on auth request", "connector_id", connID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + } + http.Redirect(w, r, callbackURL, http.StatusFound) + case connector.PasswordConnector: + loginURL := url.URL{ + Path: s.absPath("/auth", connID, "login"), + } + q := loginURL.Query() + q.Set("state", authReq.ID) + q.Set("back", backLink) + loginURL.RawQuery = q.Encode() + + http.Redirect(w, r, loginURL.String(), http.StatusFound) + case connector.SAMLConnector: + action, value, err := conn.POSTData(scopes, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "creating SAML data", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Connector Login Error") + return + } + + // TODO(ericchiang): Don't inline this. + fmt.Fprintf(w, ` + + + + SAML login + + +
+ + +
+ + + `, action, value, authReq.ID) + default: + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + } + default: + s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") + } +} + +func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + authID := r.URL.Query().Get("state") + if authID == "" { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + + backLink := r.URL.Query().Get("back") + + authReq, err := s.storage.GetAuthRequest(ctx, authID) + if err != nil { + if err == storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + s.logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to parse connector", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist") + return + } else if connID != "" && connID != authReq.ConnectorID { + s.logger.ErrorContext(r.Context(), "connector mismatch: password login triggered for different connector from authentication start", "start_connector_id", authReq.ConnectorID, "password_connector_id", connID) + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + conn, err := s.getConnector(ctx, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Connector failed to initialize.") + return + } + + pwConn, ok := conn.Connector.(connector.PasswordConnector) + if !ok { + s.logger.ErrorContext(r.Context(), "expected password connector in handlePasswordLogin()", "password_connector", pwConn) + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + rememberMe := s.rememberMeDefault() + + switch r.Method { + case http.MethodGet: + if err := s.templates.password(r, w, r.URL.String(), "", usernamePrompt(pwConn), false, backLink, rememberMe); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } + case http.MethodPost: + username := r.FormValue("login") + password := r.FormValue("password") + scopes := parseScopes(authReq.Scopes) + + identity, ok, err := pwConn.Login(r.Context(), scopes, username, password) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to login user", "err", err) + s.renderError(r, w, http.StatusInternalServerError, ErrMsgLoginError) + return + } + if !ok { + if err := s.templates.password(r, w, r.URL.String(), username, usernamePrompt(pwConn), true, backLink, rememberMe); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } + s.logger.ErrorContext(r.Context(), "failed login attempt: Invalid credentials.", "user", username) + return + } + redirectURL, canSkipApproval, err := s.finalizeLogin(r.Context(), identity, authReq, conn.Connector) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to finalize login", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + rememberMe := r.FormValue("remember_me") == "on" + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, rememberMe); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + + if canSkipApproval { + // authReq was already re-read after finalizeLogin above. + s.sendCodeResponse(w, r, authReq) + return + } + + http.Redirect(w, r, redirectURL, http.StatusSeeOther) + default: + s.renderError(r, w, http.StatusBadRequest, "Unsupported request method.") + } +} + +func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var authID string + switch r.Method { + case http.MethodGet: // OAuth2 callback + if authID = r.URL.Query().Get("state"); authID == "" { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + case http.MethodPost: // SAML POST binding + if authID = r.PostFormValue("RelayState"); authID == "" { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + default: + s.renderError(r, w, http.StatusBadRequest, "Method not supported") + return + } + + authReq, err := s.storage.GetAuthRequest(ctx, authID) + if err != nil { + if err == storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "invalid 'state' parameter provided", "err", err) + s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.") + return + } + s.logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + + connID, err := url.PathUnescape(mux.Vars(r)["connector"]) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } else if connID != "" && connID != authReq.ConnectorID { + s.logger.ErrorContext(r.Context(), "connector mismatch: callback triggered for different connector than authentication start", "authentication_start_connector_id", authReq.ConnectorID, "connector_id", connID) + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + conn, err := s.getConnector(ctx, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get connector", "connector_id", authReq.ConnectorID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + var identity connector.Identity + switch conn := conn.Connector.(type) { + case connector.CallbackConnector: + if r.Method != http.MethodGet { + s.logger.ErrorContext(r.Context(), "SAML request mapped to OAuth2 connector") + s.renderError(r, w, http.StatusBadRequest, "Invalid request") + return + } + identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), authReq.ConnectorData, r) + case connector.SAMLConnector: + if r.Method != http.MethodPost { + s.logger.ErrorContext(r.Context(), "OAuth2 request mapped to SAML connector") + s.renderError(r, w, http.StatusBadRequest, "Invalid request") + return + } + identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID) + default: + s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.") + return + } + + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to authenticate", "err", err) + var groupsErr *connector.UserNotInRequiredGroupsError + if errors.As(err, &groupsErr) { + s.renderError(r, w, http.StatusForbidden, ErrMsgNotInRequiredGroups) + } else { + s.renderError(r, w, http.StatusInternalServerError, ErrMsgAuthenticationFailed) + } + return + } + + redirectURL, canSkipApproval, err := s.finalizeLogin(ctx, identity, authReq, conn.Connector) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to finalize login", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + // Re-read auth request after finalizeLogin populated Claims. + authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get finalized auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Login error.") + return + } + + // Connector callbacks don't render the remember_me checkbox, so we use the server default. + // The password login handler reads r.FormValue("remember_me") from the submitted form instead. + if err := s.createOrUpdateAuthSession(ctx, r, w, authReq, s.sessionConfig != nil && s.sessionConfig.RememberMeCheckedByDefault); err != nil { + s.logger.ErrorContext(ctx, "failed to create/update auth session", "err", err) + } + + if canSkipApproval { + // authReq was already re-read after finalizeLogin above. + s.sendCodeResponse(w, r, authReq) + return + } + + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +// finalizeLogin associates the user's identity with the current AuthRequest, then returns +// the approval page's path. +func (s *Server) finalizeLogin(ctx context.Context, identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, bool, error) { + claims := storage.Claims{ + UserID: identity.UserID, + Username: identity.Username, + PreferredUsername: identity.PreferredUsername, + Email: identity.Email, + EmailVerified: identity.EmailVerified, + Groups: identity.Groups, + } + + updater := func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.LoggedIn = true + a.Claims = claims + a.ConnectorData = identity.ConnectorData + a.AuthTime = s.now() + return a, nil + } + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, updater); err != nil { + return "", false, fmt.Errorf("failed to update auth request: %v", err) + } + + email := claims.Email + if !claims.EmailVerified { + email += " (unverified)" + } + + s.logger.InfoContext(ctx, "login successful", + "connector_id", authReq.ConnectorID, "user_id", claims.UserID, + "username", claims.Username, "preferred_username", claims.PreferredUsername, + "email", email, "groups", claims.Groups) + + offlineAccessRequested := false + for _, scope := range authReq.Scopes { + if scope == scopeOfflineAccess { + offlineAccessRequested = true + break + } + } + _, canRefresh := conn.(connector.RefreshConnector) + + if offlineAccessRequested && canRefresh { + // Try to retrieve an existing OfflineSession object for the corresponding user. + session, err := s.storage.GetOfflineSessions(ctx, identity.UserID, authReq.ConnectorID) + switch { + case err != nil && err == storage.ErrNotFound: + offlineSessions := storage.OfflineSessions{ + UserID: identity.UserID, + ConnID: authReq.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + ConnectorData: identity.ConnectorData, + } + + // Create a new OfflineSession object for the user and add a reference object for + // the newly received refreshtoken. + if err := s.storage.CreateOfflineSessions(ctx, offlineSessions); err != nil { + s.logger.ErrorContext(ctx, "failed to create offline session", "err", err) + return "", false, err + } + case err == nil: + // Update existing OfflineSession obj with new RefreshTokenRef. + if err := s.storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + if len(identity.ConnectorData) > 0 { + old.ConnectorData = identity.ConnectorData + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update offline session", "err", err) + return "", false, err + } + default: + s.logger.ErrorContext(ctx, "failed to get offline session", "err", err) + return "", false, err + } + } + + // Create or update UserIdentity to persist user claims across sessions. + var userIdentity *storage.UserIdentity + if featureflags.SessionsEnabled.Enabled() { + now := s.now() + + ui, err := s.storage.GetUserIdentity(ctx, identity.UserID, authReq.ConnectorID) + switch { + case err != nil && errors.Is(err, storage.ErrNotFound): + ui = storage.UserIdentity{ + UserID: identity.UserID, + ConnectorID: authReq.ConnectorID, + Claims: claims, + Consents: make(map[string][]string), + CreatedAt: now, + LastLogin: now, + } + if err := s.storage.CreateUserIdentity(ctx, ui); err != nil { + s.logger.ErrorContext(ctx, "failed to create user identity", "err", err) + return "", false, err + } + case err == nil: + if err := s.storage.UpdateUserIdentity(ctx, identity.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + old.Claims = claims + old.LastLogin = now + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update user identity", "err", err) + return "", false, err + } + // Update the existing UserIdentity obj with new claims to use them later in the flow. + ui.Claims = claims + ui.LastLogin = now + default: + s.logger.ErrorContext(ctx, "failed to get user identity", "err", err) + return "", false, err + } + userIdentity = &ui + } + + // Check if the client requires MFA. + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + return "", false, fmt.Errorf("failed to get MFA chain for client: %v", err) + } + if len(mfaChain) > 0 { + return s.buildMFARedirectURL(authReq, mfaChain[0]), false, nil + } + + // No MFA required — mark as validated. + if err := s.storage.UpdateAuthRequest(ctx, authReq.ID, func(a storage.AuthRequest) (storage.AuthRequest, error) { + a.MFAValidated = true + return a, nil + }); err != nil { + return "", false, fmt.Errorf("failed to update auth request MFA status: %v", err) + } + + // Skip approval if globally configured. + if s.skipApproval && !authReq.ForceApprovalPrompt { + return "", true, nil + } + + // Skip approval if user already consented to the requested scopes for this client. + if !authReq.ForceApprovalPrompt && userIdentity != nil { + if scopesCoveredByConsent(userIdentity.Consents[authReq.ClientID], authReq.Scopes) { + return "", true, nil + } + } + + return s.buildApprovalURL(authReq), false, nil +} + +func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + macEncoded := r.FormValue("hmac") + if macEncoded == "" { + s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + authReq, err := s.storage.GetAuthRequest(ctx, r.FormValue("req")) + if err != nil { + if err == storage.ErrNotFound { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + return + } + s.logger.ErrorContext(r.Context(), "failed to get auth request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Database error.") + return + } + if !authReq.LoggedIn { + s.logger.ErrorContext(r.Context(), "auth request does not have an identity for approval") + s.renderError(r, w, http.StatusInternalServerError, "Login process not yet finalized.") + return + } + + if !authReq.MFAValidated { + // Check if MFA is actually required — if so, redirect to TOTP instead of blocking. + // This handles the case where MFA was enabled after the auth flow started. + mfaChain, err := s.mfaChainForClient(ctx, authReq.ClientID, authReq.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "failed to get MFA chain", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + if len(mfaChain) > 0 { + http.Redirect(w, r, s.buildMFARedirectURL(authReq, mfaChain[0]), http.StatusSeeOther) + return + } + // No MFA required but flag not set — allow through (backward compat). + } + + if !verifyHMAC(authReq.HMACKey, macEncoded, authReq.ID, "") { + s.renderError(r, w, http.StatusUnauthorized, "Unauthorized request") + return + } + + switch r.Method { + case http.MethodGet: + // Skip the approval page and issue the code directly if: + // 1. The client didn't force the approval prompt, AND + // 2. Either the server is configured to skip approval globally, + // or the user has already consented to all requested scopes for this client. + // This handles the MFA redirect case: after MFA completion the user lands on + // /approval via GET, and we don't want to show the consent screen again. + if !authReq.ForceApprovalPrompt { + if s.skipApproval { + s.sendCodeResponse(w, r, authReq) + return + } + ui, err := s.storage.GetUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID) + if err == nil && scopesCoveredByConsent(ui.Consents[authReq.ClientID], authReq.Scopes) { + s.sendCodeResponse(w, r, authReq) + return + } + } + + client, err := s.storage.GetClient(ctx, authReq.ClientID) + if err != nil { + s.logger.ErrorContext(r.Context(), "Failed to get client", "client_id", authReq.ClientID, "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Failed to retrieve client.") + return + } + if err := s.templates.approval(r, w, authReq.ID, authReq.Claims.Username, client.Name, authReq.Scopes); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } + case http.MethodPost: + if r.FormValue("approval") != "approve" { + s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") + return + } + // Persist user-approved scopes as consent for this client. + if featureflags.SessionsEnabled.Enabled() { + if err := s.storage.UpdateUserIdentity(ctx, authReq.Claims.UserID, authReq.ConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) { + if old.Consents == nil { + old.Consents = make(map[string][]string) + } + old.Consents[authReq.ClientID] = authReq.Scopes + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update user identity consents", "err", err) + } + } + s.sendCodeResponse(w, r, authReq) + } +} + +func (s *Server) sendCodeResponse(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { + s.updateSessionTokenIssuedAt(r, authReq.ClientID) + + ctx := r.Context() + if s.now().After(authReq.Expiry) { + s.renderError(r, w, http.StatusBadRequest, "User session has expired.") + return + } + + if err := s.storage.DeleteAuthRequest(ctx, authReq.ID); err != nil { + if err != storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "Failed to delete authorization request", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + } else { + s.renderError(r, w, http.StatusBadRequest, "User session error.") + } + return + } + u, err := url.Parse(authReq.RedirectURI) + if err != nil { + s.renderError(r, w, http.StatusInternalServerError, "Invalid redirect URI.") + return + } + + var ( + // Was the initial request using the implicit or hybrid flow instead of + // the "normal" code flow? + implicitOrHybrid = false + + // Only present in hybrid or code flow. code.ID == "" if this is not set. + code storage.AuthCode + + // ID token returned immediately if the response_type includes "id_token". + // Only valid for implicit and hybrid flows. + idToken string + idTokenExpiry time.Time + + // Access token + accessToken string + ) + + for _, responseType := range authReq.ResponseTypes { + switch responseType { + case responseTypeCode: + code = storage.AuthCode{ + ID: storage.NewID(), + ClientID: authReq.ClientID, + ConnectorID: authReq.ConnectorID, + Nonce: authReq.Nonce, + Scopes: authReq.Scopes, + Claims: authReq.Claims, + Expiry: s.now().Add(time.Minute * 30), + RedirectURI: authReq.RedirectURI, + ConnectorData: authReq.ConnectorData, + PKCE: authReq.PKCE, + AuthTime: authReq.AuthTime, + } + if err := s.storage.CreateAuthCode(ctx, code); err != nil { + s.logger.ErrorContext(r.Context(), "Failed to create auth code", "err", err) + s.renderError(r, w, http.StatusInternalServerError, "Internal server error.") + return + } + + // Implicit and hybrid flows that try to use the OOB redirect URI are + // rejected earlier. If we got here we're using the code flow. + if authReq.RedirectURI == redirectURIOOB { + if err := s.templates.oob(r, w, code.ID); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } + return + } + case responseTypeToken: + implicitOrHybrid = true + var err error + + accessToken, _, err = s.newAccessToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, authReq.ConnectorID, authReq.AuthTime) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + case responseTypeIDToken: + implicitOrHybrid = true + var err error + + idToken, idTokenExpiry, err = s.newIDToken(r.Context(), authReq.ClientID, authReq.Claims, authReq.Scopes, authReq.Nonce, accessToken, code.ID, authReq.ConnectorID, authReq.AuthTime) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to create ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + } + + if implicitOrHybrid { + v := url.Values{} + if accessToken != "" { + v.Set("access_token", accessToken) + v.Set("token_type", "bearer") + // The hybrid flow with "code token" or "code id_token token" doesn't return an + // "expires_in" value. If "code" wasn't provided, indicating the implicit flow, + // don't add it. + // + // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthResponse + if code.ID == "" { + v.Set("expires_in", strconv.Itoa(int(idTokenExpiry.Sub(s.now()).Seconds()))) + } + } + v.Set("state", authReq.State) + if idToken != "" { + v.Set("id_token", idToken) + } + if code.ID != "" { + v.Set("code", code.ID) + } + + // Implicit and hybrid flows return their values as part of the fragment. + // + // HTTP/1.1 303 See Other + // Location: https://client.example.org/cb# + // access_token=SlAV32hkKG + // &token_type=bearer + // &id_token=eyJ0 ... NiJ9.eyJ1c ... I6IjIifX0.DeWt4Qu ... ZXso + // &expires_in=3600 + // &state=af0ifjsldkj + // + u.Fragment = v.Encode() + } else { + // The code flow add values to the URL query. + // + // HTTP/1.1 303 See Other + // Location: https://client.example.org/cb? + // code=SplxlOBeZQQYbYS6WxSbIA + // &state=af0ifjsldkj + // + q := u.Query() + q.Set("code", code.ID) + q.Set("state", authReq.State) + u.RawQuery = q.Encode() + } + + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +// scopesCoveredByConsent checks whether the approved scopes cover all requested scopes. +// The openid scope is excluded from the comparison as it is a technical scope +// that does not require user consent. +func scopesCoveredByConsent(approved, requested []string) bool { + approvedSet := make(map[string]struct{}, len(approved)) + for _, s := range approved { + approvedSet[s] = struct{}{} + } + + for _, scope := range requested { + if scope == scopeOpenID { + continue + } + if _, ok := approvedSet[scope]; !ok { + return false + } + } + + return true +} + +func (s *Server) withClientFromStorage(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request, storage.Client)) { + ctx := r.Context() + clientID, clientSecret, ok := r.BasicAuth() + if ok { + var err error + if clientID, err = url.QueryUnescape(clientID); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "client_id improperly encoded", http.StatusBadRequest) + return + } + if clientSecret, err = url.QueryUnescape(clientSecret); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "client_secret improperly encoded", http.StatusBadRequest) + return + } + } else { + clientID = r.PostFormValue("client_id") + clientSecret = r.PostFormValue("client_secret") + } + + client, err := s.storage.GetClient(ctx, clientID) + if err != nil { + if err != storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "failed to get client", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + } else { + s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) + } + return + } + + if subtle.ConstantTimeCompare([]byte(client.Secret), []byte(clientSecret)) != 1 { + if clientSecret == "" { + s.logger.InfoContext(r.Context(), "missing client_secret on token request", "client_id", client.ID) + } else { + s.logger.InfoContext(r.Context(), "invalid client_secret on token request", "client_id", client.ID) + } + s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized) + return + } + + handler(w, r, client) +} + +func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + s.tokenErrHelper(w, errInvalidRequest, "method not allowed", http.StatusBadRequest) + return + } + + err := r.ParseForm() + if err != nil { + s.logger.ErrorContext(r.Context(), "could not parse request body", "err", err) + s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest) + return + } + + grantType := r.PostFormValue("grant_type") + if !contains(s.supportedGrantTypes, grantType) { + s.logger.ErrorContext(r.Context(), "unsupported grant type", "grant_type", grantType) + s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest) + return + } + switch grantType { + case grantTypeDeviceCode: + s.handleDeviceToken(w, r) + case grantTypeAuthorizationCode: + s.withClientFromStorage(w, r, s.handleAuthCode) + case grantTypeRefreshToken: + s.withClientFromStorage(w, r, s.handleRefreshToken) + case grantTypePassword: + s.withClientFromStorage(w, r, s.handlePasswordGrant) + case grantTypeTokenExchange: + s.withClientFromStorage(w, r, s.handleTokenExchange) + case grantTypeClientCredentials: + s.withClientFromStorage(w, r, s.handleClientCredentialsGrant) + default: + s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest) + } +} + +func (s *Server) calculateCodeChallenge(codeVerifier, codeChallengeMethod string) (string, error) { + switch codeChallengeMethod { + case codeChallengeMethodPlain: + return codeVerifier, nil + case codeChallengeMethodS256: + shaSum := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(shaSum[:]), nil + default: + return "", fmt.Errorf("unknown challenge method (%v)", codeChallengeMethod) + } +} + +// handle an access token request https://tools.ietf.org/html/rfc6749#section-4.1.3 +func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + code := r.PostFormValue("code") + redirectURI := r.PostFormValue("redirect_uri") + + if code == "" { + s.tokenErrHelper(w, errInvalidRequest, `Required param: code.`, http.StatusBadRequest) + return + } + + authCode, err := s.storage.GetAuthCode(ctx, code) + if err != nil || s.now().After(authCode.Expiry) || authCode.ClientID != client.ID { + if err != storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "failed to get auth code", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + } else { + s.tokenErrHelper(w, errInvalidGrant, "Invalid or expired code parameter.", http.StatusBadRequest) + } + return + } + + // RFC 7636 (PKCE) + codeChallengeFromStorage := authCode.PKCE.CodeChallenge + providedCodeVerifier := r.PostFormValue("code_verifier") + + switch { + case providedCodeVerifier != "" && codeChallengeFromStorage != "": + calculatedCodeChallenge, err := s.calculateCodeChallenge(providedCodeVerifier, authCode.PKCE.CodeChallengeMethod) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to calculate code challenge", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + if codeChallengeFromStorage != calculatedCodeChallenge { + s.tokenErrHelper(w, errInvalidGrant, "Invalid code_verifier.", http.StatusBadRequest) + return + } + case providedCodeVerifier != "": + // Received no code_challenge on /auth, but a code_verifier on /token + s.tokenErrHelper(w, errInvalidRequest, "No PKCE flow started. Cannot check code_verifier.", http.StatusBadRequest) + return + case codeChallengeFromStorage != "": + // Received PKCE request on /auth, but no code_verifier on /token + s.tokenErrHelper(w, errInvalidGrant, "Expecting parameter code_verifier in PKCE flow.", http.StatusBadRequest) + return + } + + if authCode.RedirectURI != redirectURI { + s.tokenErrHelper(w, errInvalidRequest, "redirect_uri did not match URI from initial request.", http.StatusBadRequest) + return + } + + tokenResponse, err := s.exchangeAuthCode(ctx, w, authCode, client) + if err != nil { + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + s.writeAccessToken(w, tokenResponse) +} + +func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) { + accessToken, _, err := s.newAccessToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID, authCode.AuthTime) + if err != nil { + s.logger.ErrorContext(ctx, "failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return nil, err + } + + idToken, expiry, err := s.newIDToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ID, authCode.ConnectorID, authCode.AuthTime) + if err != nil { + s.logger.ErrorContext(ctx, "failed to create ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return nil, err + } + + if err := s.storage.DeleteAuthCode(ctx, authCode.ID); err != nil { + s.logger.ErrorContext(ctx, "failed to delete auth code", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return nil, err + } + + reqRefresh := func() bool { + // Determine whether to issue a refresh token. A refresh token is only + // issued when all of the following are true: + // 1. The connector implements RefreshConnector. + // 2. The connector's grantTypes config allows refresh_token. + // 3. The client requested the offline_access scope. + // + // When any condition is not met, the refresh token is silently omitted + // rather than returning an error. This matches the OAuth2 spec: the + // server is never required to issue a refresh token (RFC 6749 §1.5). + // https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 + conn, err := s.getConnector(ctx, authCode.ConnectorID) + if err != nil { + s.logger.ErrorContext(ctx, "connector not found", "connector_id", authCode.ConnectorID, "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return false + } + + _, ok := conn.Connector.(connector.RefreshConnector) + if !ok { + return false + } + + if !GrantTypeAllowed(conn.GrantTypes, grantTypeRefreshToken) { + return false + } + + for _, scope := range authCode.Scopes { + if scope == scopeOfflineAccess { + return true + } + } + return false + }() + var refreshToken string + if reqRefresh { + refresh := storage.RefreshToken{ + ID: storage.NewID(), + Token: storage.NewID(), + ClientID: authCode.ClientID, + ConnectorID: authCode.ConnectorID, + Scopes: authCode.Scopes, + Claims: authCode.Claims, + Nonce: authCode.Nonce, + ConnectorData: authCode.ConnectorData, + CreatedAt: s.now(), + LastUsed: s.now(), + } + token := &internal.RefreshToken{ + RefreshId: refresh.ID, + Token: refresh.Token, + } + if refreshToken, err = internal.Marshal(token); err != nil { + s.logger.ErrorContext(ctx, "failed to marshal refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return nil, err + } + + if err := s.storage.CreateRefresh(ctx, refresh); err != nil { + s.logger.ErrorContext(ctx, "failed to create refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return nil, err + } + + // deleteToken determines if we need to delete the newly created refresh token + // due to a failure in updating/creating the OfflineSession object for the + // corresponding user. + var deleteToken bool + defer func() { + if deleteToken { + // Delete newly created refresh token from storage. + if err := s.storage.DeleteRefresh(ctx, refresh.ID); err != nil { + s.logger.ErrorContext(ctx, "failed to delete refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + }() + + tokenRef := storage.RefreshTokenRef{ + ID: refresh.ID, + ClientID: refresh.ClientID, + CreatedAt: refresh.CreatedAt, + LastUsed: refresh.LastUsed, + } + + // Try to retrieve an existing OfflineSession object for the corresponding user. + if session, err := s.storage.GetOfflineSessions(ctx, refresh.Claims.UserID, refresh.ConnectorID); err != nil { + if err != storage.ErrNotFound { + s.logger.ErrorContext(ctx, "failed to get offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return nil, err + } + offlineSessions := storage.OfflineSessions{ + UserID: refresh.Claims.UserID, + ConnID: refresh.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + ConnectorData: refresh.ConnectorData, + } + offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef + + // Create a new OfflineSession object for the user and add a reference object for + // the newly received refreshtoken. + if err := s.storage.CreateOfflineSessions(ctx, offlineSessions); err != nil { + s.logger.ErrorContext(ctx, "failed to create offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return nil, err + } + } else { + if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok { + // Delete old refresh token from storage. + if err := s.storage.DeleteRefresh(ctx, oldTokenRef.ID); err != nil && err != storage.ErrNotFound { + s.logger.ErrorContext(ctx, "failed to delete refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return nil, err + } + } + + // Update existing OfflineSession obj with new RefreshTokenRef. + if err := s.storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + old.Refresh[tokenRef.ClientID] = &tokenRef + if len(refresh.ConnectorData) > 0 { + old.ConnectorData = refresh.ConnectorData + } + return old, nil + }); err != nil { + s.logger.ErrorContext(ctx, "failed to update offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return nil, err + } + } + } + return s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry), nil +} + +func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + const prefix = "Bearer " + + auth := r.Header.Get("authorization") + if len(auth) < len(prefix) || !strings.EqualFold(prefix, auth[:len(prefix)]) { + w.Header().Set("WWW-Authenticate", "Bearer") + s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusUnauthorized) + return + } + rawIDToken := auth[len(prefix):] + + verifier := oidc.NewVerifier(s.issuerURL.String(), &signerKeySet{s.signer}, &oidc.Config{SkipClientIDCheck: true}) + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to verify ID token", "err", err) + s.tokenErrHelper(w, errAccessDenied, "Invalid bearer token.", http.StatusForbidden) + return + } + + var claims json.RawMessage + if err := idToken.Claims(&claims); err != nil { + s.logger.ErrorContext(r.Context(), "failed to decode ID token claims", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(claims) +} + +func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + // Parse the fields + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + q := r.Form + + nonce := q.Get("nonce") + // Some clients, like the old go-oidc, provide extra whitespace. Tolerate this. + scopes := strings.Fields(q.Get("scope")) + + // Parse the scopes if they are passed + var ( + unrecognized []string + invalidScopes []string + ) + hasOpenIDScope := false + for _, scope := range scopes { + switch scope { + case scopeOpenID: + hasOpenIDScope = true + case scopeOfflineAccess, scopeEmail, scopeProfile, scopeGroups, scopeFederatedID: + default: + peerID, ok := parseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + + isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID) + if err != nil { + s.logger.ErrorContext(r.Context(), "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest) + return + } + if !isTrusted { + invalidScopes = append(invalidScopes, scope) + } + } + } + if !hasOpenIDScope { + s.tokenErrHelper(w, errInvalidRequest, `Missing required scope(s) ["openid"].`, http.StatusBadRequest) + return + } + if len(unrecognized) > 0 { + s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest) + return + } + if len(invalidScopes) > 0 { + s.tokenErrHelper(w, errInvalidRequest, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest) + return + } + + // Which connector + connID := s.passwordConnector + conn, err := s.getConnector(ctx, connID) + if err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) + return + } + if !GrantTypeAllowed(conn.GrantTypes, grantTypePassword) { + s.logger.ErrorContext(r.Context(), "connector does not allow password grant", "connector_id", connID) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not support password grant.", http.StatusBadRequest) + return + } + + passwordConnector, ok := conn.Connector.(connector.PasswordConnector) + if !ok { + s.tokenErrHelper(w, errInvalidRequest, "Requested password connector does not correct type.", http.StatusBadRequest) + return + } + + // Login + username := q.Get("username") + password := q.Get("password") + identity, ok, err := passwordConnector.Login(ctx, parseScopes(scopes), username, password) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to login user", "err", err) + s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest) + return + } + if !ok { + s.tokenErrHelper(w, errAccessDenied, "Invalid username or password", http.StatusUnauthorized) + return + } + + // Build the claims to send the id token + claims := storage.Claims{ + UserID: identity.UserID, + Username: identity.Username, + PreferredUsername: identity.PreferredUsername, + Email: identity.Email, + EmailVerified: identity.EmailVerified, + Groups: identity.Groups, + } + + accessToken, _, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID, time.Time{}) + if err != nil { + s.logger.ErrorContext(r.Context(), "password grant failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + idToken, expiry, err := s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID, time.Time{}) + if err != nil { + s.logger.ErrorContext(r.Context(), "password grant failed to create new ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + reqRefresh := func() bool { + // Same logic as in exchangeAuthCode: silently omit refresh token + // when the connector doesn't support it or grantTypes forbids it. + // See RFC 6749 §1.5 — refresh tokens are never mandatory. + // https://datatracker.ietf.org/doc/html/rfc6749#section-1.5 + if _, ok := conn.Connector.(connector.RefreshConnector); !ok { + return false + } + + if !GrantTypeAllowed(conn.GrantTypes, grantTypeRefreshToken) { + return false + } + + for _, scope := range scopes { + if scope == scopeOfflineAccess { + return true + } + } + return false + }() + var refreshToken string + if reqRefresh { + refresh := storage.RefreshToken{ + ID: storage.NewID(), + Token: storage.NewID(), + ClientID: client.ID, + ConnectorID: connID, + Scopes: scopes, + Claims: claims, + Nonce: nonce, + // ConnectorData: authCode.ConnectorData, + CreatedAt: s.now(), + LastUsed: s.now(), + } + token := &internal.RefreshToken{ + RefreshId: refresh.ID, + Token: refresh.Token, + } + if refreshToken, err = internal.Marshal(token); err != nil { + s.logger.ErrorContext(r.Context(), "failed to marshal refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + if err := s.storage.CreateRefresh(ctx, refresh); err != nil { + s.logger.ErrorContext(r.Context(), "failed to create refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + // deleteToken determines if we need to delete the newly created refresh token + // due to a failure in updating/creating the OfflineSession object for the + // corresponding user. + var deleteToken bool + defer func() { + if deleteToken { + // Delete newly created refresh token from storage. + if err := s.storage.DeleteRefresh(ctx, refresh.ID); err != nil { + s.logger.ErrorContext(r.Context(), "failed to delete refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + }() + + tokenRef := storage.RefreshTokenRef{ + ID: refresh.ID, + ClientID: refresh.ClientID, + CreatedAt: refresh.CreatedAt, + LastUsed: refresh.LastUsed, + } + + // Try to retrieve an existing OfflineSession object for the corresponding user. + if session, err := s.storage.GetOfflineSessions(ctx, refresh.Claims.UserID, refresh.ConnectorID); err != nil { + if err != storage.ErrNotFound { + s.logger.ErrorContext(r.Context(), "failed to get offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return + } + offlineSessions := storage.OfflineSessions{ + UserID: refresh.Claims.UserID, + ConnID: refresh.ConnectorID, + Refresh: make(map[string]*storage.RefreshTokenRef), + ConnectorData: identity.ConnectorData, + } + offlineSessions.Refresh[tokenRef.ClientID] = &tokenRef + + // Create a new OfflineSession object for the user and add a reference object for + // the newly received refreshtoken. + if err := s.storage.CreateOfflineSessions(ctx, offlineSessions); err != nil { + s.logger.ErrorContext(r.Context(), "failed to create offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return + } + } else { + if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok { + // Delete old refresh token from storage. + if err := s.storage.DeleteRefresh(ctx, oldTokenRef.ID); err != nil { + if err == storage.ErrNotFound { + s.logger.Warn("database inconsistent, refresh token missing", "token_id", oldTokenRef.ID) + } else { + s.logger.ErrorContext(r.Context(), "failed to delete refresh token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return + } + } + } + + // Update existing OfflineSession obj with new RefreshTokenRef. + if err := s.storage.UpdateOfflineSessions(ctx, session.UserID, session.ConnID, func(old storage.OfflineSessions) (storage.OfflineSessions, error) { + old.Refresh[tokenRef.ClientID] = &tokenRef + old.ConnectorData = identity.ConnectorData + return old, nil + }); err != nil { + s.logger.ErrorContext(r.Context(), "failed to update offline session", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + deleteToken = true + return + } + } + } + + resp := s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry) + s.writeAccessToken(w, resp) +} + +func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + s.logger.ErrorContext(r.Context(), "could not parse request body", "err", err) + s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest) + return + } + q := r.Form + + scopes := strings.Fields(q.Get("scope")) // OPTIONAL, map to issued token scope + requestedTokenType := q.Get("requested_token_type") // OPTIONAL, default to access token + if requestedTokenType == "" { + requestedTokenType = tokenTypeAccess + } + subjectToken := q.Get("subject_token") // REQUIRED + subjectTokenType := q.Get("subject_token_type") // REQUIRED + connID := q.Get("connector_id") // REQUIRED, not in RFC + + switch subjectTokenType { + case tokenTypeID, tokenTypeAccess: // ok, continue + default: + s.tokenErrHelper(w, errRequestNotSupported, "Invalid subject_token_type.", http.StatusBadRequest) + return + } + + if subjectToken == "" { + s.tokenErrHelper(w, errInvalidRequest, "Missing subject_token", http.StatusBadRequest) + return + } + + conn, err := s.getConnector(ctx, connID) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to get connector", "err", err) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) + return + } + if !GrantTypeAllowed(conn.GrantTypes, grantTypeTokenExchange) { + s.logger.ErrorContext(r.Context(), "connector does not allow token exchange", "connector_id", connID) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not support token exchange.", http.StatusBadRequest) + return + } + teConn, ok := conn.Connector.(connector.TokenIdentityConnector) + if !ok { + s.logger.ErrorContext(r.Context(), "connector doesn't implement token exchange", "connector_id", connID) + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) + return + } + identity, err := teConn.TokenIdentity(ctx, subjectTokenType, subjectToken) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to verify subject token", "err", err) + s.tokenErrHelper(w, errAccessDenied, "", http.StatusUnauthorized) + return + } + + claims := storage.Claims{ + UserID: identity.UserID, + Username: identity.Username, + PreferredUsername: identity.PreferredUsername, + Email: identity.Email, + EmailVerified: identity.EmailVerified, + Groups: identity.Groups, + } + resp := accessTokenResponse{ + IssuedTokenType: requestedTokenType, + TokenType: "bearer", + } + var expiry time.Time + switch requestedTokenType { + case tokenTypeID: + resp.AccessToken, expiry, err = s.newIDToken(r.Context(), client.ID, claims, scopes, "", "", "", connID, time.Time{}) + case tokenTypeAccess: + resp.AccessToken, expiry, err = s.newAccessToken(r.Context(), client.ID, claims, scopes, "", connID, time.Time{}) + default: + s.tokenErrHelper(w, errRequestNotSupported, "Invalid requested_token_type.", http.StatusBadRequest) + return + } + if err != nil { + s.logger.ErrorContext(r.Context(), "token exchange failed to create new token", "requested_token_type", requestedTokenType, "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + resp.ExpiresIn = int(time.Until(expiry).Seconds()) + + // Token response must include cache headers https://tools.ietf.org/html/rfc6749#section-5.1 + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + + // client_credentials requires a confidential client. + if client.Public { + s.tokenErrHelper(w, errUnauthorizedClient, "Public clients cannot use client_credentials grant.", http.StatusBadRequest) + return + } + + // Parse scopes from request. + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + scopes := strings.Fields(r.Form.Get("scope")) + + // Validate scopes. + var ( + unrecognized []string + invalidScopes []string + ) + hasOpenIDScope := false + for _, scope := range scopes { + switch scope { + case scopeOpenID: + hasOpenIDScope = true + case scopeEmail, scopeProfile, scopeGroups: + // allowed + case scopeOfflineAccess: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support offline_access scope.", http.StatusBadRequest) + return + case scopeFederatedID: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support federated:id scope.", http.StatusBadRequest) + return + default: + peerID, ok := parseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + + isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID) + if err != nil { + s.logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest) + return + } + if !isTrusted { + invalidScopes = append(invalidScopes, scope) + } + } + } + if len(unrecognized) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest) + return + } + if len(invalidScopes) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest) + return + } + + // Build claims from the client itself — no user involved. + claims := storage.Claims{ + UserID: client.ID, + } + + // Only populate Username/PreferredUsername when the profile scope is requested. + for _, scope := range scopes { + if scope == scopeProfile { + claims.Username = client.Name + claims.PreferredUsername = client.Name + break + } + } + + nonce := r.Form.Get("nonce") + + // Empty connector ID is unique for cluster credentials grant + // Creating connectors with an empty ID with the config and API is prohibited + connID := "" + + accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID, time.Time{}) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + var idToken string + if hasOpenIDScope { + idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID, time.Time{}) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + + resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry) + s.writeAccessToken(w, resp) +} + +type accessTokenResponse struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type,omitempty"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +func (s *Server) toAccessTokenResponse(idToken, accessToken, refreshToken string, expiry time.Time) *accessTokenResponse { + return &accessTokenResponse{ + AccessToken: accessToken, + TokenType: "bearer", + ExpiresIn: int(expiry.Sub(s.now()).Seconds()), + RefreshToken: refreshToken, + IDToken: idToken, + } +} + +func (s *Server) writeAccessToken(w http.ResponseWriter, resp *accessTokenResponse) { + data, err := json.Marshal(resp) + if err != nil { + // TODO(nabokihms): error with context + s.logger.Error("failed to marshal access token response", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + + // Token response must include cache headers https://tools.ietf.org/html/rfc6749#section-5.1 + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.Write(data) +} + +func (s *Server) renderError(r *http.Request, w http.ResponseWriter, status int, description string) { + if err := s.templates.err(r, w, status, description); err != nil { + s.logger.ErrorContext(r.Context(), "server template error", "err", err) + } +} + +func (s *Server) tokenErrHelper(w http.ResponseWriter, typ string, description string, statusCode int) { + if err := tokenErr(w, typ, description, statusCode); err != nil { + // TODO(nabokihms): error with context + s.logger.Error("token error response", "err", err) + } +} + +// Check for username prompt override from connector. Defaults to "Username". +func usernamePrompt(conn connector.PasswordConnector) string { + if attr := conn.Prompt(); attr != "" { + return attr + } + return "Username" +}