diff --git a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl index 5d7277bf..a53d6f53 100644 --- a/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl +++ b/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go.tmpl @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 70d3865d..8ebd5eb5 100644 --- a/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/deploy/supervisor/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -56,19 +56,103 @@ spec: the OAuth2 authorization request parameters to be used with this OIDC identity provider. properties: + additionalAuthorizeParameters: + description: additionalAuthorizeParameters are extra query parameters + that should be included in the authorize request to your OIDC + provider in the authorization request during an OIDC Authorization + Code Flow. By default, no extra parameters are sent. The standard + parameters that will be sent are "response_type", "scope", "client_id", + "state", "nonce", "code_challenge", "code_challenge_method", + and "redirect_uri". These parameters cannot be included in this + setting. Additionally, the "hd" parameter cannot be included + in this setting at this time. The "hd" parameter is used by + Google's OIDC provider to provide a hint as to which "hosted + domain" the user should use during login. However, Pinniped + does not yet support validating the hosted domain in the resulting + ID token, so it is not yet safe to use this feature of Google's + OIDC provider with Pinniped. This setting does not influence + the parameters sent to the token endpoint in the Resource Owner + Password Credentials Grant. The Pinniped Supervisor requires + that your OIDC provider returns refresh tokens to the Supervisor + from the authorization flows. Some OIDC providers may require + a certain value for the "prompt" parameter in order to properly + request refresh tokens. See the documentation of your OIDC provider's + authorization endpoint for its requirements for what to include + in the request in order to receive a refresh token in the response, + if anything. If your provider requires the prompt parameter + to request a refresh token, then include it here. Also note + that most providers also require a certain scope to be requested + in order to receive refresh tokens. See the additionalScopes + setting for more information about using scopes to request refresh + tokens. + items: + description: Parameter is a key/value pair which represents + a parameter in an HTTP request. + properties: + name: + description: The name of the parameter. Required. + minLength: 1 + type: string + value: + description: The value of the parameter. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map additionalScopes: - description: AdditionalScopes are the scopes in addition to "openid" - that will be requested as part of the authorization request - flow with an OIDC identity provider. In the case of a Resource - Owner Password Credentials Grant flow, AdditionalScopes are - the scopes in addition to "openid" that will be requested as - part of the token request (see also the allowPasswordGrant field). - By default, only the "openid" scope will be requested. + description: 'additionalScopes are the additional scopes that + will be requested from your OIDC provider in the authorization + request during an OIDC Authorization Code Flow and in the token + request during a Resource Owner Password Credentials Grant. + Note that the "openid" scope will always be requested regardless + of the value in this setting, since it is always required according + to the OIDC spec. By default, when this field is not set, the + Supervisor will request the following scopes: "openid", "offline_access", + "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + for a description of the "offline_access" scope. This default + value may change in future versions of Pinniped as the standard + evolves, or as common patterns used by providers who implement + the standard in the ecosystem evolve. By setting this list to + anything other than an empty list, you are overriding the default + value, so you may wish to include some of "offline_access", + "email", and "profile" in your override list. If you do not + want any of these scopes to be requested, you may set this list + to contain only "openid". Some OIDC providers may also require + a scope to get access to the user''s group membership, in which + case you may wish to include it in this list. Sometimes the + scope to request the user''s group membership is called "groups", + but unfortunately this is not specified in the OIDC standard. + Generally speaking, you should include any scopes required to + cause the appropriate claims to be the returned by your OIDC + provider in the ID token or userinfo endpoint results for those + claims which you would like to use in the oidcClaims settings + to determine the usernames and group memberships of your Kubernetes + users. See your OIDC provider''s documentation for more information + about what scopes are available to request claims. Additionally, + the Pinniped Supervisor requires that your OIDC provider returns + refresh tokens to the Supervisor from these authorization flows. + For most OIDC providers, the scope required to receive refresh + tokens will be "offline_access". See the documentation of your + OIDC provider''s authorization and token endpoints for its requirements + for what to include in the request in order to receive a refresh + token in the response, if anything. Note that it may be safe + to send "offline_access" even to providers which do not require + it, since the provider may ignore scopes that it does not understand + or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + In the unusual case that you must avoid sending the "offline_access" + scope, then you must override the default value of this setting. + This is required if your OIDC provider will reject the request + when it includes "offline_access" (e.g. GitLab''s OIDC provider).' items: type: string type: array allowPasswordGrant: - description: AllowPasswordGrant, when true, will allow the use + description: allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password @@ -94,7 +178,7 @@ spec: your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider - during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. type: boolean type: object @@ -103,12 +187,19 @@ spec: used when inspecting an identity from this OIDC identity provider. properties: groups: - description: Groups provides the name of the token claim that - will be used to ascertain the groups to which an identity belongs. + description: Groups provides the name of the ID token claim or + userinfo endpoint response claim that will be used to ascertain + the groups to which an identity belongs. By default, the identities + will not include any group memberships when this setting is + not configured. type: string username: - description: Username provides the name of the token claim that - will be used to ascertain an identity's username. + description: Username provides the name of the ID token claim + or userinfo endpoint response claim that will be used to ascertain + an identity's username. When not set, the username will be an + automatically constructed unique string which will include the + issuer URL of your OIDC provider along with the value of the + "sub" (subject) claim from the ID token. type: string type: object client: diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc index 479b7026..d18e7610 100644 --- a/generated/1.17/README.adoc +++ b/generated/1.17/README.adoc @@ -1099,8 +1099,9 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. -| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. +| *`additionalScopes`* __string array__ | additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request the following scopes: "openid", "offline_access", "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, or as common patterns used by providers who implement the standard in the ecosystem evolve. By setting this list to anything other than an empty list, you are overriding the default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. If you do not want any of these scopes to be requested, you may set this list to contain only "openid". Some OIDC providers may also require a scope to get access to the user's group membership, in which case you may wish to include it in this list. Sometimes the scope to request the user's group membership is called "groups", but unfortunately this is not specified in the OIDC standard. Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See your OIDC provider's documentation for more information about what scopes are available to request claims. Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider may ignore scopes that it does not understand or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). +| *`additionalAuthorizeParameters`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-parameter[$$Parameter$$] array__ | additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's authorization endpoint for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, then include it here. Also note that most providers also require a certain scope to be requested in order to receive refresh tokens. See the additionalScopes setting for more information about using scopes to request refresh tokens. +| *`allowPasswordGrant`* __boolean__ | allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. |=== @@ -1117,8 +1118,8 @@ OIDCClaims provides a mapping from upstream claims into identities. [cols="25a,75a", options="header"] |=== | Field | Description -| *`groups`* __string__ | Groups provides the name of the token claim that will be used to ascertain the groups to which an identity belongs. -| *`username`* __string__ | Username provides the name of the token claim that will be used to ascertain an identity's username. +| *`groups`* __string__ | Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain the groups to which an identity belongs. By default, the identities will not include any group memberships when this setting is not configured. +| *`username`* __string__ | Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain an identity's username. When not set, the username will be an automatically constructed unique string which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from the ID token. |=== @@ -1164,7 +1165,7 @@ OIDCIdentityProvider describes the configuration of an upstream OpenID Connect i [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec"] ==== OIDCIdentityProviderSpec -Spec for configuring an OIDC identity provider. +OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. .Appears In: **** @@ -1185,7 +1186,7 @@ Spec for configuring an OIDC identity provider. [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus"] ==== OIDCIdentityProviderStatus -Status of an OIDC identity provider. +OIDCIdentityProviderStatus is the status of an OIDC identity provider. .Appears In: **** @@ -1200,6 +1201,24 @@ Status of an OIDC identity provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-parameter"] +==== Parameter + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | The name of the parameter. Required. +| *`value`* __string__ | The value of the parameter. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-supervisor-idp-v1alpha1-tlsspec"] ==== TLSSpec diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 5d7277bf..a53d6f53 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 9895a76e..12e67583 100644 --- a/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.17/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -416,6 +416,11 @@ func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalAuthorizeParameters != nil { + in, out := &in.AdditionalAuthorizeParameters, &out.AdditionalAuthorizeParameters + *out = make([]Parameter, len(*in)) + copy(*out, *in) + } return } @@ -569,6 +574,22 @@ func (in *OIDCIdentityProviderStatus) DeepCopy() *OIDCIdentityProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameter) DeepCopyInto(out *Parameter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameter. +func (in *Parameter) DeepCopy() *Parameter { + if in == nil { + return nil + } + out := new(Parameter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = *in diff --git a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 70d3865d..8ebd5eb5 100644 --- a/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.17/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -56,19 +56,103 @@ spec: the OAuth2 authorization request parameters to be used with this OIDC identity provider. properties: + additionalAuthorizeParameters: + description: additionalAuthorizeParameters are extra query parameters + that should be included in the authorize request to your OIDC + provider in the authorization request during an OIDC Authorization + Code Flow. By default, no extra parameters are sent. The standard + parameters that will be sent are "response_type", "scope", "client_id", + "state", "nonce", "code_challenge", "code_challenge_method", + and "redirect_uri". These parameters cannot be included in this + setting. Additionally, the "hd" parameter cannot be included + in this setting at this time. The "hd" parameter is used by + Google's OIDC provider to provide a hint as to which "hosted + domain" the user should use during login. However, Pinniped + does not yet support validating the hosted domain in the resulting + ID token, so it is not yet safe to use this feature of Google's + OIDC provider with Pinniped. This setting does not influence + the parameters sent to the token endpoint in the Resource Owner + Password Credentials Grant. The Pinniped Supervisor requires + that your OIDC provider returns refresh tokens to the Supervisor + from the authorization flows. Some OIDC providers may require + a certain value for the "prompt" parameter in order to properly + request refresh tokens. See the documentation of your OIDC provider's + authorization endpoint for its requirements for what to include + in the request in order to receive a refresh token in the response, + if anything. If your provider requires the prompt parameter + to request a refresh token, then include it here. Also note + that most providers also require a certain scope to be requested + in order to receive refresh tokens. See the additionalScopes + setting for more information about using scopes to request refresh + tokens. + items: + description: Parameter is a key/value pair which represents + a parameter in an HTTP request. + properties: + name: + description: The name of the parameter. Required. + minLength: 1 + type: string + value: + description: The value of the parameter. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map additionalScopes: - description: AdditionalScopes are the scopes in addition to "openid" - that will be requested as part of the authorization request - flow with an OIDC identity provider. In the case of a Resource - Owner Password Credentials Grant flow, AdditionalScopes are - the scopes in addition to "openid" that will be requested as - part of the token request (see also the allowPasswordGrant field). - By default, only the "openid" scope will be requested. + description: 'additionalScopes are the additional scopes that + will be requested from your OIDC provider in the authorization + request during an OIDC Authorization Code Flow and in the token + request during a Resource Owner Password Credentials Grant. + Note that the "openid" scope will always be requested regardless + of the value in this setting, since it is always required according + to the OIDC spec. By default, when this field is not set, the + Supervisor will request the following scopes: "openid", "offline_access", + "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + for a description of the "offline_access" scope. This default + value may change in future versions of Pinniped as the standard + evolves, or as common patterns used by providers who implement + the standard in the ecosystem evolve. By setting this list to + anything other than an empty list, you are overriding the default + value, so you may wish to include some of "offline_access", + "email", and "profile" in your override list. If you do not + want any of these scopes to be requested, you may set this list + to contain only "openid". Some OIDC providers may also require + a scope to get access to the user''s group membership, in which + case you may wish to include it in this list. Sometimes the + scope to request the user''s group membership is called "groups", + but unfortunately this is not specified in the OIDC standard. + Generally speaking, you should include any scopes required to + cause the appropriate claims to be the returned by your OIDC + provider in the ID token or userinfo endpoint results for those + claims which you would like to use in the oidcClaims settings + to determine the usernames and group memberships of your Kubernetes + users. See your OIDC provider''s documentation for more information + about what scopes are available to request claims. Additionally, + the Pinniped Supervisor requires that your OIDC provider returns + refresh tokens to the Supervisor from these authorization flows. + For most OIDC providers, the scope required to receive refresh + tokens will be "offline_access". See the documentation of your + OIDC provider''s authorization and token endpoints for its requirements + for what to include in the request in order to receive a refresh + token in the response, if anything. Note that it may be safe + to send "offline_access" even to providers which do not require + it, since the provider may ignore scopes that it does not understand + or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + In the unusual case that you must avoid sending the "offline_access" + scope, then you must override the default value of this setting. + This is required if your OIDC provider will reject the request + when it includes "offline_access" (e.g. GitLab''s OIDC provider).' items: type: string type: array allowPasswordGrant: - description: AllowPasswordGrant, when true, will allow the use + description: allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password @@ -94,7 +178,7 @@ spec: your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider - during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. type: boolean type: object @@ -103,12 +187,19 @@ spec: used when inspecting an identity from this OIDC identity provider. properties: groups: - description: Groups provides the name of the token claim that - will be used to ascertain the groups to which an identity belongs. + description: Groups provides the name of the ID token claim or + userinfo endpoint response claim that will be used to ascertain + the groups to which an identity belongs. By default, the identities + will not include any group memberships when this setting is + not configured. type: string username: - description: Username provides the name of the token claim that - will be used to ascertain an identity's username. + description: Username provides the name of the ID token claim + or userinfo endpoint response claim that will be used to ascertain + an identity's username. When not set, the username will be an + automatically constructed unique string which will include the + issuer URL of your OIDC provider along with the value of the + "sub" (subject) claim from the ID token. type: string type: object client: diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc index 1e0f5b16..417b57ac 100644 --- a/generated/1.18/README.adoc +++ b/generated/1.18/README.adoc @@ -1099,8 +1099,9 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. -| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. +| *`additionalScopes`* __string array__ | additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request the following scopes: "openid", "offline_access", "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, or as common patterns used by providers who implement the standard in the ecosystem evolve. By setting this list to anything other than an empty list, you are overriding the default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. If you do not want any of these scopes to be requested, you may set this list to contain only "openid". Some OIDC providers may also require a scope to get access to the user's group membership, in which case you may wish to include it in this list. Sometimes the scope to request the user's group membership is called "groups", but unfortunately this is not specified in the OIDC standard. Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See your OIDC provider's documentation for more information about what scopes are available to request claims. Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider may ignore scopes that it does not understand or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). +| *`additionalAuthorizeParameters`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-parameter[$$Parameter$$] array__ | additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's authorization endpoint for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, then include it here. Also note that most providers also require a certain scope to be requested in order to receive refresh tokens. See the additionalScopes setting for more information about using scopes to request refresh tokens. +| *`allowPasswordGrant`* __boolean__ | allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. |=== @@ -1117,8 +1118,8 @@ OIDCClaims provides a mapping from upstream claims into identities. [cols="25a,75a", options="header"] |=== | Field | Description -| *`groups`* __string__ | Groups provides the name of the token claim that will be used to ascertain the groups to which an identity belongs. -| *`username`* __string__ | Username provides the name of the token claim that will be used to ascertain an identity's username. +| *`groups`* __string__ | Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain the groups to which an identity belongs. By default, the identities will not include any group memberships when this setting is not configured. +| *`username`* __string__ | Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain an identity's username. When not set, the username will be an automatically constructed unique string which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from the ID token. |=== @@ -1164,7 +1165,7 @@ OIDCIdentityProvider describes the configuration of an upstream OpenID Connect i [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec"] ==== OIDCIdentityProviderSpec -Spec for configuring an OIDC identity provider. +OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. .Appears In: **** @@ -1185,7 +1186,7 @@ Spec for configuring an OIDC identity provider. [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus"] ==== OIDCIdentityProviderStatus -Status of an OIDC identity provider. +OIDCIdentityProviderStatus is the status of an OIDC identity provider. .Appears In: **** @@ -1200,6 +1201,24 @@ Status of an OIDC identity provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-parameter"] +==== Parameter + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | The name of the parameter. Required. +| *`value`* __string__ | The value of the parameter. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-supervisor-idp-v1alpha1-tlsspec"] ==== TLSSpec diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 5d7277bf..a53d6f53 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 9895a76e..12e67583 100644 --- a/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.18/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -416,6 +416,11 @@ func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalAuthorizeParameters != nil { + in, out := &in.AdditionalAuthorizeParameters, &out.AdditionalAuthorizeParameters + *out = make([]Parameter, len(*in)) + copy(*out, *in) + } return } @@ -569,6 +574,22 @@ func (in *OIDCIdentityProviderStatus) DeepCopy() *OIDCIdentityProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameter) DeepCopyInto(out *Parameter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameter. +func (in *Parameter) DeepCopy() *Parameter { + if in == nil { + return nil + } + out := new(Parameter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = *in diff --git a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 70d3865d..8ebd5eb5 100644 --- a/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.18/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -56,19 +56,103 @@ spec: the OAuth2 authorization request parameters to be used with this OIDC identity provider. properties: + additionalAuthorizeParameters: + description: additionalAuthorizeParameters are extra query parameters + that should be included in the authorize request to your OIDC + provider in the authorization request during an OIDC Authorization + Code Flow. By default, no extra parameters are sent. The standard + parameters that will be sent are "response_type", "scope", "client_id", + "state", "nonce", "code_challenge", "code_challenge_method", + and "redirect_uri". These parameters cannot be included in this + setting. Additionally, the "hd" parameter cannot be included + in this setting at this time. The "hd" parameter is used by + Google's OIDC provider to provide a hint as to which "hosted + domain" the user should use during login. However, Pinniped + does not yet support validating the hosted domain in the resulting + ID token, so it is not yet safe to use this feature of Google's + OIDC provider with Pinniped. This setting does not influence + the parameters sent to the token endpoint in the Resource Owner + Password Credentials Grant. The Pinniped Supervisor requires + that your OIDC provider returns refresh tokens to the Supervisor + from the authorization flows. Some OIDC providers may require + a certain value for the "prompt" parameter in order to properly + request refresh tokens. See the documentation of your OIDC provider's + authorization endpoint for its requirements for what to include + in the request in order to receive a refresh token in the response, + if anything. If your provider requires the prompt parameter + to request a refresh token, then include it here. Also note + that most providers also require a certain scope to be requested + in order to receive refresh tokens. See the additionalScopes + setting for more information about using scopes to request refresh + tokens. + items: + description: Parameter is a key/value pair which represents + a parameter in an HTTP request. + properties: + name: + description: The name of the parameter. Required. + minLength: 1 + type: string + value: + description: The value of the parameter. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map additionalScopes: - description: AdditionalScopes are the scopes in addition to "openid" - that will be requested as part of the authorization request - flow with an OIDC identity provider. In the case of a Resource - Owner Password Credentials Grant flow, AdditionalScopes are - the scopes in addition to "openid" that will be requested as - part of the token request (see also the allowPasswordGrant field). - By default, only the "openid" scope will be requested. + description: 'additionalScopes are the additional scopes that + will be requested from your OIDC provider in the authorization + request during an OIDC Authorization Code Flow and in the token + request during a Resource Owner Password Credentials Grant. + Note that the "openid" scope will always be requested regardless + of the value in this setting, since it is always required according + to the OIDC spec. By default, when this field is not set, the + Supervisor will request the following scopes: "openid", "offline_access", + "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + for a description of the "offline_access" scope. This default + value may change in future versions of Pinniped as the standard + evolves, or as common patterns used by providers who implement + the standard in the ecosystem evolve. By setting this list to + anything other than an empty list, you are overriding the default + value, so you may wish to include some of "offline_access", + "email", and "profile" in your override list. If you do not + want any of these scopes to be requested, you may set this list + to contain only "openid". Some OIDC providers may also require + a scope to get access to the user''s group membership, in which + case you may wish to include it in this list. Sometimes the + scope to request the user''s group membership is called "groups", + but unfortunately this is not specified in the OIDC standard. + Generally speaking, you should include any scopes required to + cause the appropriate claims to be the returned by your OIDC + provider in the ID token or userinfo endpoint results for those + claims which you would like to use in the oidcClaims settings + to determine the usernames and group memberships of your Kubernetes + users. See your OIDC provider''s documentation for more information + about what scopes are available to request claims. Additionally, + the Pinniped Supervisor requires that your OIDC provider returns + refresh tokens to the Supervisor from these authorization flows. + For most OIDC providers, the scope required to receive refresh + tokens will be "offline_access". See the documentation of your + OIDC provider''s authorization and token endpoints for its requirements + for what to include in the request in order to receive a refresh + token in the response, if anything. Note that it may be safe + to send "offline_access" even to providers which do not require + it, since the provider may ignore scopes that it does not understand + or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + In the unusual case that you must avoid sending the "offline_access" + scope, then you must override the default value of this setting. + This is required if your OIDC provider will reject the request + when it includes "offline_access" (e.g. GitLab''s OIDC provider).' items: type: string type: array allowPasswordGrant: - description: AllowPasswordGrant, when true, will allow the use + description: allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password @@ -94,7 +178,7 @@ spec: your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider - during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. type: boolean type: object @@ -103,12 +187,19 @@ spec: used when inspecting an identity from this OIDC identity provider. properties: groups: - description: Groups provides the name of the token claim that - will be used to ascertain the groups to which an identity belongs. + description: Groups provides the name of the ID token claim or + userinfo endpoint response claim that will be used to ascertain + the groups to which an identity belongs. By default, the identities + will not include any group memberships when this setting is + not configured. type: string username: - description: Username provides the name of the token claim that - will be used to ascertain an identity's username. + description: Username provides the name of the ID token claim + or userinfo endpoint response claim that will be used to ascertain + an identity's username. When not set, the username will be an + automatically constructed unique string which will include the + issuer URL of your OIDC provider along with the value of the + "sub" (subject) claim from the ID token. type: string type: object client: diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc index f1bae9ef..0317faed 100644 --- a/generated/1.19/README.adoc +++ b/generated/1.19/README.adoc @@ -1099,8 +1099,9 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. -| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. +| *`additionalScopes`* __string array__ | additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request the following scopes: "openid", "offline_access", "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, or as common patterns used by providers who implement the standard in the ecosystem evolve. By setting this list to anything other than an empty list, you are overriding the default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. If you do not want any of these scopes to be requested, you may set this list to contain only "openid". Some OIDC providers may also require a scope to get access to the user's group membership, in which case you may wish to include it in this list. Sometimes the scope to request the user's group membership is called "groups", but unfortunately this is not specified in the OIDC standard. Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See your OIDC provider's documentation for more information about what scopes are available to request claims. Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider may ignore scopes that it does not understand or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). +| *`additionalAuthorizeParameters`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-parameter[$$Parameter$$] array__ | additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's authorization endpoint for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, then include it here. Also note that most providers also require a certain scope to be requested in order to receive refresh tokens. See the additionalScopes setting for more information about using scopes to request refresh tokens. +| *`allowPasswordGrant`* __boolean__ | allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. |=== @@ -1117,8 +1118,8 @@ OIDCClaims provides a mapping from upstream claims into identities. [cols="25a,75a", options="header"] |=== | Field | Description -| *`groups`* __string__ | Groups provides the name of the token claim that will be used to ascertain the groups to which an identity belongs. -| *`username`* __string__ | Username provides the name of the token claim that will be used to ascertain an identity's username. +| *`groups`* __string__ | Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain the groups to which an identity belongs. By default, the identities will not include any group memberships when this setting is not configured. +| *`username`* __string__ | Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain an identity's username. When not set, the username will be an automatically constructed unique string which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from the ID token. |=== @@ -1164,7 +1165,7 @@ OIDCIdentityProvider describes the configuration of an upstream OpenID Connect i [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec"] ==== OIDCIdentityProviderSpec -Spec for configuring an OIDC identity provider. +OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. .Appears In: **** @@ -1185,7 +1186,7 @@ Spec for configuring an OIDC identity provider. [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus"] ==== OIDCIdentityProviderStatus -Status of an OIDC identity provider. +OIDCIdentityProviderStatus is the status of an OIDC identity provider. .Appears In: **** @@ -1200,6 +1201,24 @@ Status of an OIDC identity provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-parameter"] +==== Parameter + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | The name of the parameter. Required. +| *`value`* __string__ | The value of the parameter. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-supervisor-idp-v1alpha1-tlsspec"] ==== TLSSpec diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 5d7277bf..a53d6f53 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 9895a76e..12e67583 100644 --- a/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.19/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -416,6 +416,11 @@ func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalAuthorizeParameters != nil { + in, out := &in.AdditionalAuthorizeParameters, &out.AdditionalAuthorizeParameters + *out = make([]Parameter, len(*in)) + copy(*out, *in) + } return } @@ -569,6 +574,22 @@ func (in *OIDCIdentityProviderStatus) DeepCopy() *OIDCIdentityProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameter) DeepCopyInto(out *Parameter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameter. +func (in *Parameter) DeepCopy() *Parameter { + if in == nil { + return nil + } + out := new(Parameter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = *in diff --git a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 70d3865d..8ebd5eb5 100644 --- a/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.19/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -56,19 +56,103 @@ spec: the OAuth2 authorization request parameters to be used with this OIDC identity provider. properties: + additionalAuthorizeParameters: + description: additionalAuthorizeParameters are extra query parameters + that should be included in the authorize request to your OIDC + provider in the authorization request during an OIDC Authorization + Code Flow. By default, no extra parameters are sent. The standard + parameters that will be sent are "response_type", "scope", "client_id", + "state", "nonce", "code_challenge", "code_challenge_method", + and "redirect_uri". These parameters cannot be included in this + setting. Additionally, the "hd" parameter cannot be included + in this setting at this time. The "hd" parameter is used by + Google's OIDC provider to provide a hint as to which "hosted + domain" the user should use during login. However, Pinniped + does not yet support validating the hosted domain in the resulting + ID token, so it is not yet safe to use this feature of Google's + OIDC provider with Pinniped. This setting does not influence + the parameters sent to the token endpoint in the Resource Owner + Password Credentials Grant. The Pinniped Supervisor requires + that your OIDC provider returns refresh tokens to the Supervisor + from the authorization flows. Some OIDC providers may require + a certain value for the "prompt" parameter in order to properly + request refresh tokens. See the documentation of your OIDC provider's + authorization endpoint for its requirements for what to include + in the request in order to receive a refresh token in the response, + if anything. If your provider requires the prompt parameter + to request a refresh token, then include it here. Also note + that most providers also require a certain scope to be requested + in order to receive refresh tokens. See the additionalScopes + setting for more information about using scopes to request refresh + tokens. + items: + description: Parameter is a key/value pair which represents + a parameter in an HTTP request. + properties: + name: + description: The name of the parameter. Required. + minLength: 1 + type: string + value: + description: The value of the parameter. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map additionalScopes: - description: AdditionalScopes are the scopes in addition to "openid" - that will be requested as part of the authorization request - flow with an OIDC identity provider. In the case of a Resource - Owner Password Credentials Grant flow, AdditionalScopes are - the scopes in addition to "openid" that will be requested as - part of the token request (see also the allowPasswordGrant field). - By default, only the "openid" scope will be requested. + description: 'additionalScopes are the additional scopes that + will be requested from your OIDC provider in the authorization + request during an OIDC Authorization Code Flow and in the token + request during a Resource Owner Password Credentials Grant. + Note that the "openid" scope will always be requested regardless + of the value in this setting, since it is always required according + to the OIDC spec. By default, when this field is not set, the + Supervisor will request the following scopes: "openid", "offline_access", + "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + for a description of the "offline_access" scope. This default + value may change in future versions of Pinniped as the standard + evolves, or as common patterns used by providers who implement + the standard in the ecosystem evolve. By setting this list to + anything other than an empty list, you are overriding the default + value, so you may wish to include some of "offline_access", + "email", and "profile" in your override list. If you do not + want any of these scopes to be requested, you may set this list + to contain only "openid". Some OIDC providers may also require + a scope to get access to the user''s group membership, in which + case you may wish to include it in this list. Sometimes the + scope to request the user''s group membership is called "groups", + but unfortunately this is not specified in the OIDC standard. + Generally speaking, you should include any scopes required to + cause the appropriate claims to be the returned by your OIDC + provider in the ID token or userinfo endpoint results for those + claims which you would like to use in the oidcClaims settings + to determine the usernames and group memberships of your Kubernetes + users. See your OIDC provider''s documentation for more information + about what scopes are available to request claims. Additionally, + the Pinniped Supervisor requires that your OIDC provider returns + refresh tokens to the Supervisor from these authorization flows. + For most OIDC providers, the scope required to receive refresh + tokens will be "offline_access". See the documentation of your + OIDC provider''s authorization and token endpoints for its requirements + for what to include in the request in order to receive a refresh + token in the response, if anything. Note that it may be safe + to send "offline_access" even to providers which do not require + it, since the provider may ignore scopes that it does not understand + or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + In the unusual case that you must avoid sending the "offline_access" + scope, then you must override the default value of this setting. + This is required if your OIDC provider will reject the request + when it includes "offline_access" (e.g. GitLab''s OIDC provider).' items: type: string type: array allowPasswordGrant: - description: AllowPasswordGrant, when true, will allow the use + description: allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password @@ -94,7 +178,7 @@ spec: your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider - during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. type: boolean type: object @@ -103,12 +187,19 @@ spec: used when inspecting an identity from this OIDC identity provider. properties: groups: - description: Groups provides the name of the token claim that - will be used to ascertain the groups to which an identity belongs. + description: Groups provides the name of the ID token claim or + userinfo endpoint response claim that will be used to ascertain + the groups to which an identity belongs. By default, the identities + will not include any group memberships when this setting is + not configured. type: string username: - description: Username provides the name of the token claim that - will be used to ascertain an identity's username. + description: Username provides the name of the ID token claim + or userinfo endpoint response claim that will be used to ascertain + an identity's username. When not set, the username will be an + automatically constructed unique string which will include the + issuer URL of your OIDC provider along with the value of the + "sub" (subject) claim from the ID token. type: string type: object client: diff --git a/generated/1.20/README.adoc b/generated/1.20/README.adoc index efcde7ab..faf7ad54 100644 --- a/generated/1.20/README.adoc +++ b/generated/1.20/README.adoc @@ -1099,8 +1099,9 @@ OIDCAuthorizationConfig provides information about how to form the OAuth2 author [cols="25a,75a", options="header"] |=== | Field | Description -| *`additionalScopes`* __string array__ | AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization request flow with an OIDC identity provider. In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). By default, only the "openid" scope will be requested. -| *`allowPasswordGrant`* __boolean__ | AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. AllowPasswordGrant defaults to false. +| *`additionalScopes`* __string array__ | additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request the following scopes: "openid", "offline_access", "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, or as common patterns used by providers who implement the standard in the ecosystem evolve. By setting this list to anything other than an empty list, you are overriding the default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. If you do not want any of these scopes to be requested, you may set this list to contain only "openid". Some OIDC providers may also require a scope to get access to the user's group membership, in which case you may wish to include it in this list. Sometimes the scope to request the user's group membership is called "groups", but unfortunately this is not specified in the OIDC standard. Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See your OIDC provider's documentation for more information about what scopes are available to request claims. Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider may ignore scopes that it does not understand or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). +| *`additionalAuthorizeParameters`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-parameter[$$Parameter$$] array__ | additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's authorization endpoint for its requirements for what to include in the request in order to receive a refresh token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, then include it here. Also note that most providers also require a certain scope to be requested in order to receive refresh tokens. See the additionalScopes setting for more information about using scopes to request refresh tokens. +| *`allowPasswordGrant`* __boolean__ | allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be supported by your OIDC provider. If your OIDC provider supports returning ID tokens from a Resource Owner Password Credentials Grant token request, then you can choose to set this field to true. This will allow end users to choose to present their username and password to the kubectl CLI (using the Pinniped plugin) to authenticate to the cluster, without using a web browser to log in as is customary in OIDC Authorization Code Flow. This may be convenient for users, especially for identities from your OIDC provider which are not intended to represent a human actor, such as service accounts performing actions in a CI/CD environment. Even if your OIDC provider supports it, you may wish to disable this behavior by setting this field to false when you prefer to only allow users of this OIDCIdentityProvider to log in via the browser-based OIDC Authorization Code Flow. Using the Resource Owner Password Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. |=== @@ -1117,8 +1118,8 @@ OIDCClaims provides a mapping from upstream claims into identities. [cols="25a,75a", options="header"] |=== | Field | Description -| *`groups`* __string__ | Groups provides the name of the token claim that will be used to ascertain the groups to which an identity belongs. -| *`username`* __string__ | Username provides the name of the token claim that will be used to ascertain an identity's username. +| *`groups`* __string__ | Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain the groups to which an identity belongs. By default, the identities will not include any group memberships when this setting is not configured. +| *`username`* __string__ | Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain an identity's username. When not set, the username will be an automatically constructed unique string which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from the ID token. |=== @@ -1164,7 +1165,7 @@ OIDCIdentityProvider describes the configuration of an upstream OpenID Connect i [id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcidentityproviderspec"] ==== OIDCIdentityProviderSpec -Spec for configuring an OIDC identity provider. +OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. .Appears In: **** @@ -1185,7 +1186,7 @@ Spec for configuring an OIDC identity provider. [id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcidentityproviderstatus"] ==== OIDCIdentityProviderStatus -Status of an OIDC identity provider. +OIDCIdentityProviderStatus is the status of an OIDC identity provider. .Appears In: **** @@ -1200,6 +1201,24 @@ Status of an OIDC identity provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-parameter"] +==== Parameter + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-oidcauthorizationconfig[$$OIDCAuthorizationConfig$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | The name of the parameter. Required. +| *`value`* __string__ | The value of the parameter. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-supervisor-idp-v1alpha1-tlsspec"] ==== TLSSpec diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 5d7277bf..a53d6f53 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 9895a76e..12e67583 100644 --- a/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.20/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -416,6 +416,11 @@ func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalAuthorizeParameters != nil { + in, out := &in.AdditionalAuthorizeParameters, &out.AdditionalAuthorizeParameters + *out = make([]Parameter, len(*in)) + copy(*out, *in) + } return } @@ -569,6 +574,22 @@ func (in *OIDCIdentityProviderStatus) DeepCopy() *OIDCIdentityProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameter) DeepCopyInto(out *Parameter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameter. +func (in *Parameter) DeepCopy() *Parameter { + if in == nil { + return nil + } + out := new(Parameter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = *in diff --git a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml index 70d3865d..8ebd5eb5 100644 --- a/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml +++ b/generated/1.20/crds/idp.supervisor.pinniped.dev_oidcidentityproviders.yaml @@ -56,19 +56,103 @@ spec: the OAuth2 authorization request parameters to be used with this OIDC identity provider. properties: + additionalAuthorizeParameters: + description: additionalAuthorizeParameters are extra query parameters + that should be included in the authorize request to your OIDC + provider in the authorization request during an OIDC Authorization + Code Flow. By default, no extra parameters are sent. The standard + parameters that will be sent are "response_type", "scope", "client_id", + "state", "nonce", "code_challenge", "code_challenge_method", + and "redirect_uri". These parameters cannot be included in this + setting. Additionally, the "hd" parameter cannot be included + in this setting at this time. The "hd" parameter is used by + Google's OIDC provider to provide a hint as to which "hosted + domain" the user should use during login. However, Pinniped + does not yet support validating the hosted domain in the resulting + ID token, so it is not yet safe to use this feature of Google's + OIDC provider with Pinniped. This setting does not influence + the parameters sent to the token endpoint in the Resource Owner + Password Credentials Grant. The Pinniped Supervisor requires + that your OIDC provider returns refresh tokens to the Supervisor + from the authorization flows. Some OIDC providers may require + a certain value for the "prompt" parameter in order to properly + request refresh tokens. See the documentation of your OIDC provider's + authorization endpoint for its requirements for what to include + in the request in order to receive a refresh token in the response, + if anything. If your provider requires the prompt parameter + to request a refresh token, then include it here. Also note + that most providers also require a certain scope to be requested + in order to receive refresh tokens. See the additionalScopes + setting for more information about using scopes to request refresh + tokens. + items: + description: Parameter is a key/value pair which represents + a parameter in an HTTP request. + properties: + name: + description: The name of the parameter. Required. + minLength: 1 + type: string + value: + description: The value of the parameter. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map additionalScopes: - description: AdditionalScopes are the scopes in addition to "openid" - that will be requested as part of the authorization request - flow with an OIDC identity provider. In the case of a Resource - Owner Password Credentials Grant flow, AdditionalScopes are - the scopes in addition to "openid" that will be requested as - part of the token request (see also the allowPasswordGrant field). - By default, only the "openid" scope will be requested. + description: 'additionalScopes are the additional scopes that + will be requested from your OIDC provider in the authorization + request during an OIDC Authorization Code Flow and in the token + request during a Resource Owner Password Credentials Grant. + Note that the "openid" scope will always be requested regardless + of the value in this setting, since it is always required according + to the OIDC spec. By default, when this field is not set, the + Supervisor will request the following scopes: "openid", "offline_access", + "email", and "profile". See https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + for a description of the "profile" and "email" scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + for a description of the "offline_access" scope. This default + value may change in future versions of Pinniped as the standard + evolves, or as common patterns used by providers who implement + the standard in the ecosystem evolve. By setting this list to + anything other than an empty list, you are overriding the default + value, so you may wish to include some of "offline_access", + "email", and "profile" in your override list. If you do not + want any of these scopes to be requested, you may set this list + to contain only "openid". Some OIDC providers may also require + a scope to get access to the user''s group membership, in which + case you may wish to include it in this list. Sometimes the + scope to request the user''s group membership is called "groups", + but unfortunately this is not specified in the OIDC standard. + Generally speaking, you should include any scopes required to + cause the appropriate claims to be the returned by your OIDC + provider in the ID token or userinfo endpoint results for those + claims which you would like to use in the oidcClaims settings + to determine the usernames and group memberships of your Kubernetes + users. See your OIDC provider''s documentation for more information + about what scopes are available to request claims. Additionally, + the Pinniped Supervisor requires that your OIDC provider returns + refresh tokens to the Supervisor from these authorization flows. + For most OIDC providers, the scope required to receive refresh + tokens will be "offline_access". See the documentation of your + OIDC provider''s authorization and token endpoints for its requirements + for what to include in the request in order to receive a refresh + token in the response, if anything. Note that it may be safe + to send "offline_access" even to providers which do not require + it, since the provider may ignore scopes that it does not understand + or require (see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + In the unusual case that you must avoid sending the "offline_access" + scope, then you must override the default value of this setting. + This is required if your OIDC provider will reject the request + when it includes "offline_access" (e.g. GitLab''s OIDC provider).' items: type: string type: array allowPasswordGrant: - description: AllowPasswordGrant, when true, will allow the use + description: allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a username and password @@ -94,7 +178,7 @@ spec: your end users' passwords (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other web-based login features of your OIDC provider - during Resource Owner Password Credentials Grant logins. AllowPasswordGrant + during Resource Owner Password Credentials Grant logins. allowPasswordGrant defaults to false. type: boolean type: object @@ -103,12 +187,19 @@ spec: used when inspecting an identity from this OIDC identity provider. properties: groups: - description: Groups provides the name of the token claim that - will be used to ascertain the groups to which an identity belongs. + description: Groups provides the name of the ID token claim or + userinfo endpoint response claim that will be used to ascertain + the groups to which an identity belongs. By default, the identities + will not include any group memberships when this setting is + not configured. type: string username: - description: Username provides the name of the token claim that - will be used to ascertain an identity's username. + description: Username provides the name of the ID token claim + or userinfo endpoint response claim that will be used to ascertain + an identity's username. When not set, the username will be an + automatically constructed unique string which will include the + issuer URL of your OIDC provider along with the value of the + "sub" (subject) claim from the ID token. type: string type: object client: diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go index 5d7277bf..a53d6f53 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/types_oidcidentityprovider.go @@ -20,7 +20,7 @@ const ( PhaseError OIDCIdentityProviderPhase = "Error" ) -// Status of an OIDC identity provider. +// OIDCIdentityProviderStatus is the status of an OIDC identity provider. type OIDCIdentityProviderStatus struct { // Phase summarizes the overall status of the OIDCIdentityProvider. // +kubebuilder:default=Pending @@ -38,15 +38,62 @@ type OIDCIdentityProviderStatus struct { // OIDCAuthorizationConfig provides information about how to form the OAuth2 authorization // request parameters. type OIDCAuthorizationConfig struct { - // AdditionalScopes are the scopes in addition to "openid" that will be requested as part of the authorization - // request flow with an OIDC identity provider. - // In the case of a Resource Owner Password Credentials Grant flow, AdditionalScopes are the scopes - // in addition to "openid" that will be requested as part of the token request (see also the allowPasswordGrant field). - // By default, only the "openid" scope will be requested. + // additionalScopes are the additional scopes that will be requested from your OIDC provider in the authorization + // request during an OIDC Authorization Code Flow and in the token request during a Resource Owner Password Credentials + // Grant. Note that the "openid" scope will always be requested regardless of the value in this setting, since it is + // always required according to the OIDC spec. By default, when this field is not set, the Supervisor will request + // the following scopes: "openid", "offline_access", "email", and "profile". See + // https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims for a description of the "profile" and "email" + // scopes. See https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess for a description of the + // "offline_access" scope. This default value may change in future versions of Pinniped as the standard evolves, + // or as common patterns used by providers who implement the standard in the ecosystem evolve. + // By setting this list to anything other than an empty list, you are overriding the + // default value, so you may wish to include some of "offline_access", "email", and "profile" in your override list. + // If you do not want any of these scopes to be requested, you may set this list to contain only "openid". + // Some OIDC providers may also require a scope to get access to the user's group membership, in which case you + // may wish to include it in this list. Sometimes the scope to request the user's group membership is called + // "groups", but unfortunately this is not specified in the OIDC standard. + // Generally speaking, you should include any scopes required to cause the appropriate claims to be the returned by + // your OIDC provider in the ID token or userinfo endpoint results for those claims which you would like to use in + // the oidcClaims settings to determine the usernames and group memberships of your Kubernetes users. See + // your OIDC provider's documentation for more information about what scopes are available to request claims. + // Additionally, the Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the Supervisor + // from these authorization flows. For most OIDC providers, the scope required to receive refresh tokens will be + // "offline_access". See the documentation of your OIDC provider's authorization and token endpoints for its + // requirements for what to include in the request in order to receive a refresh token in the response, if anything. + // Note that it may be safe to send "offline_access" even to providers which do not require it, since the provider + // may ignore scopes that it does not understand or require (see + // https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). In the unusual case that you must avoid sending the + // "offline_access" scope, then you must override the default value of this setting. This is required if your OIDC + // provider will reject the request when it includes "offline_access" (e.g. GitLab's OIDC provider). // +optional AdditionalScopes []string `json:"additionalScopes,omitempty"` - // AllowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant + // additionalAuthorizeParameters are extra query parameters that should be included in the authorize request to your + // OIDC provider in the authorization request during an OIDC Authorization Code Flow. By default, no extra + // parameters are sent. The standard parameters that will be sent are "response_type", "scope", "client_id", + // "state", "nonce", "code_challenge", "code_challenge_method", and "redirect_uri". These parameters cannot be + // included in this setting. Additionally, the "hd" parameter cannot be included in this setting at this time. + // The "hd" parameter is used by Google's OIDC provider to provide a hint as to which "hosted domain" the user + // should use during login. However, Pinniped does not yet support validating the hosted domain in the resulting + // ID token, so it is not yet safe to use this feature of Google's OIDC provider with Pinniped. + // This setting does not influence the parameters sent to the token endpoint in the Resource Owner Password + // Credentials Grant. The Pinniped Supervisor requires that your OIDC provider returns refresh tokens to the + // Supervisor from the authorization flows. Some OIDC providers may require a certain value for the "prompt" + // parameter in order to properly request refresh tokens. See the documentation of your OIDC provider's + // authorization endpoint for its requirements for what to include in the request in order to receive a refresh + // token in the response, if anything. If your provider requires the prompt parameter to request a refresh token, + // then include it here. Also note that most providers also require a certain scope to be requested in order to + // receive refresh tokens. See the additionalScopes setting for more information about using scopes to request + // refresh tokens. + // +optional + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + AdditionalAuthorizeParameters []Parameter `json:"additionalAuthorizeParameters,omitempty"` + + // allowPasswordGrant, when true, will allow the use of OAuth 2.0's Resource Owner Password Credentials Grant // (see https://datatracker.ietf.org/doc/html/rfc6749#section-4.3) to authenticate to the OIDC provider using a // username and password without a web browser, in addition to the usual browser-based OIDC Authorization Code Flow. // The Resource Owner Password Credentials Grant is not officially part of the OIDC specification, so it may not be @@ -61,20 +108,34 @@ type OIDCAuthorizationConfig struct { // Credentials Grant means that the Pinniped CLI and Pinniped Supervisor will directly handle your end users' passwords // (similar to LDAPIdentityProvider), and you will not be able to require multi-factor authentication or use the other // web-based login features of your OIDC provider during Resource Owner Password Credentials Grant logins. - // AllowPasswordGrant defaults to false. + // allowPasswordGrant defaults to false. // +optional AllowPasswordGrant bool `json:"allowPasswordGrant,omitempty"` } +// Parameter is a key/value pair which represents a parameter in an HTTP request. +type Parameter struct { + // The name of the parameter. Required. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // The value of the parameter. + // +optional + Value string `json:"value,omitempty"` +} + // OIDCClaims provides a mapping from upstream claims into identities. type OIDCClaims struct { - // Groups provides the name of the token claim that will be used to ascertain the groups to which - // an identity belongs. + // Groups provides the name of the ID token claim or userinfo endpoint response claim that will be used to ascertain + // the groups to which an identity belongs. By default, the identities will not include any group memberships when + // this setting is not configured. // +optional Groups string `json:"groups"` - // Username provides the name of the token claim that will be used to ascertain an identity's - // username. + // Username provides the name of the ID token claim or userinfo endpoint response claim that will be used to + // ascertain an identity's username. When not set, the username will be an automatically constructed unique string + // which will include the issuer URL of your OIDC provider along with the value of the "sub" (subject) claim from + // the ID token. // +optional Username string `json:"username"` } @@ -89,7 +150,7 @@ type OIDCClient struct { SecretName string `json:"secretName"` } -// Spec for configuring an OIDC identity provider. +// OIDCIdentityProviderSpec is the spec for configuring an OIDC identity provider. type OIDCIdentityProviderSpec struct { // Issuer is the issuer URL of this OIDC identity provider, i.e., where to fetch // /.well-known/openid-configuration. @@ -135,7 +196,7 @@ type OIDCIdentityProvider struct { Status OIDCIdentityProviderStatus `json:"status,omitempty"` } -// List of OIDCIdentityProvider objects. +// OIDCIdentityProviderList lists OIDCIdentityProvider objects. // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type OIDCIdentityProviderList struct { metav1.TypeMeta `json:",inline"` diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 9895a76e..12e67583 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -416,6 +416,11 @@ func (in *OIDCAuthorizationConfig) DeepCopyInto(out *OIDCAuthorizationConfig) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalAuthorizeParameters != nil { + in, out := &in.AdditionalAuthorizeParameters, &out.AdditionalAuthorizeParameters + *out = make([]Parameter, len(*in)) + copy(*out, *in) + } return } @@ -569,6 +574,22 @@ func (in *OIDCIdentityProviderStatus) DeepCopy() *OIDCIdentityProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Parameter) DeepCopyInto(out *Parameter) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Parameter. +func (in *Parameter) DeepCopy() *Parameter { + if in == nil { + return nil + } + out := new(Parameter) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = *in diff --git a/go.mod b/go.mod index 1593d1f2..b5f0e8ea 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,9 @@ require ( github.com/google/uuid v1.1.2 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/websocket v1.4.2 + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 github.com/ory/fosite v0.40.2 + github.com/ory/x v0.0.212 github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 github.com/pkg/errors v0.9.1 github.com/sclevine/agouti v3.0.0+incompatible @@ -96,12 +98,10 @@ require ( github.com/moby/spdystream v0.2.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ory/go-acc v0.2.6 // indirect github.com/ory/go-convenience v0.1.0 // indirect github.com/ory/viper v1.7.5 // indirect - github.com/ory/x v0.0.212 // indirect github.com/pborman/uuid v1.2.0 // indirect github.com/pelletier/go-toml v1.9.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 90faf5bd..cce57ef5 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -372,7 +372,7 @@ export PINNIPED_TEST_CLI_OIDC_USERNAME=pinny@example.com export PINNIPED_TEST_CLI_OIDC_PASSWORD=${dex_test_password} export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER=https://dex.tools.svc.cluster.local/dex export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE="${test_ca_bundle_pem}" -export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES=email +export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES="offline_access,email" export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME_CLAIM=email export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_GROUPS_CLAIM=groups export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_CLIENT_ID=pinniped-supervisor diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index d6490846..7bd83f39 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -301,8 +301,9 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, adUpstreamImpl := &activeDirectoryUpstreamGenericLDAPImpl{activeDirectoryIdentityProvider: *upstream} config := &upstreamldap.ProviderConfig{ - Name: upstream.Name, - Host: spec.Host, + Name: upstream.Name, + ResourceUID: upstream.UID, + Host: spec.Host, UserSearch: upstreamldap.UserSearchConfig{ Base: spec.UserSearch.Base, Filter: adUpstreamImpl.Spec().UserSearch().Filter(), diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index 33aea395..da204216 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -150,6 +150,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { const ( testNamespace = "test-namespace" + testResourceUID = "test-uid" testName = "test-name" testSecretName = "test-bind-secret" testBindUsername = "test-bind-username" @@ -172,7 +173,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { testCABundleBase64Encoded := base64.StdEncoding.EncodeToString(testCABundle) validUpstream := &v1alpha1.ActiveDirectoryIdentityProvider{ - ObjectMeta: metav1.ObjectMeta{Name: testName, Namespace: testNamespace, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Name: testName, Namespace: testNamespace, Generation: 1234, UID: testResourceUID}, Spec: v1alpha1.ActiveDirectoryIdentityProviderSpec{ Host: testHost, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testCABundleBase64Encoded}, @@ -202,6 +203,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { providerConfigForValidUpstreamWithTLS := &upstreamldap.ProviderConfig{ Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -364,7 +366,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -379,7 +381,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -407,7 +409,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -434,7 +436,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -460,7 +462,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -486,7 +488,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -517,6 +519,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: nil, @@ -537,7 +540,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -572,6 +575,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: nil, @@ -592,7 +596,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -630,6 +634,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: "ldap.example.com", ConnectionProtocol: upstreamldap.StartTLS, // successfully fell back to using StartTLS CABundle: testCABundle, @@ -650,7 +655,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -688,6 +693,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { // even though the connection test failed, still loads into the cache because it is treated like a warning { Name: testName, + ResourceUID: testResourceUID, Host: "ldap.example.com:5678", ConnectionProtocol: upstreamldap.TLS, // need to pick TLS or StartTLS to load into the cache when both fail, so choose TLS CABundle: testCABundle, @@ -709,7 +715,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -745,6 +751,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: nil, @@ -765,7 +772,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -779,6 +786,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { upstream.Name = "other-upstream" upstream.Generation = 42 upstream.Spec.Bind.SecretName = "non-existent-secret" + upstream.UID = "other-uid" })}, inputSecrets: []runtime.Object{validBindUserSecret("4242")}, setupMocks: func(conn *mockldapconn.MockConn) { @@ -790,7 +798,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{ { - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-upstream", Generation: 42}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-upstream", Generation: 42, UID: "other-uid"}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -807,7 +815,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, { - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -831,7 +839,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -872,6 +880,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -892,7 +901,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -928,7 +937,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -966,7 +975,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -995,6 +1004,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1015,7 +1025,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -1045,6 +1055,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1065,7 +1076,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -1100,7 +1111,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithStartTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -1137,7 +1148,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -1175,7 +1186,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -1212,7 +1223,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -1243,6 +1254,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1264,7 +1276,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -1295,6 +1307,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1315,7 +1328,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -1350,6 +1363,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1370,7 +1384,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -1399,6 +1413,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1419,7 +1434,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -1447,7 +1462,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -1483,7 +1498,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -1525,7 +1540,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -1554,7 +1569,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -1594,6 +1609,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -1614,7 +1630,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testResourceUID, Generation: 1234}, Status: v1alpha1.ActiveDirectoryIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go index af748da1..bcbd6d91 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go @@ -226,8 +226,9 @@ func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream * spec := upstream.Spec config := &upstreamldap.ProviderConfig{ - Name: upstream.Name, - Host: spec.Host, + Name: upstream.Name, + ResourceUID: upstream.UID, + Host: spec.Host, UserSearch: upstreamldap.UserSearchConfig{ Base: spec.UserSearch.Base, Filter: spec.UserSearch.Filter, diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go index e9ab4813..c562d969 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go @@ -150,6 +150,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { const ( testNamespace = "test-namespace" testName = "test-name" + testResourceUID = "test-resource-uid" testSecretName = "test-bind-secret" testBindUsername = "test-bind-username" testBindPassword = "test-bind-password" @@ -171,7 +172,12 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { testCABundleBase64Encoded := base64.StdEncoding.EncodeToString(testCABundle) validUpstream := &v1alpha1.LDAPIdentityProvider{ - ObjectMeta: metav1.ObjectMeta{Name: testName, Namespace: testNamespace, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + Namespace: testNamespace, + Generation: 1234, + UID: testResourceUID, + }, Spec: v1alpha1.LDAPIdentityProviderSpec{ Host: testHost, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testCABundleBase64Encoded}, @@ -201,6 +207,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { providerConfigForValidUpstreamWithTLS := &upstreamldap.ProviderConfig{ Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: testCABundle, @@ -299,7 +306,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -320,7 +327,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -348,7 +355,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -375,7 +382,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -401,7 +408,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -427,7 +434,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -458,6 +465,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: nil, @@ -477,7 +485,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -519,6 +527,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: "ldap.example.com", ConnectionProtocol: upstreamldap.StartTLS, // successfully fell back to using StartTLS CABundle: testCABundle, @@ -538,7 +547,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ @@ -580,6 +589,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { // even though the connection test failed, still loads into the cache because it is treated like a warning { Name: testName, + ResourceUID: testResourceUID, Host: "ldap.example.com:5678", ConnectionProtocol: upstreamldap.TLS, // need to pick TLS or StartTLS to load into the cache when both fail, so choose TLS CABundle: testCABundle, @@ -600,7 +610,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -635,6 +645,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{ { Name: testName, + ResourceUID: testResourceUID, Host: testHost, ConnectionProtocol: upstreamldap.TLS, CABundle: nil, @@ -654,7 +665,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, }, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -674,6 +685,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { upstream.Name = "other-upstream" upstream.Generation = 42 upstream.Spec.Bind.SecretName = "non-existent-secret" + upstream.UID = "other-uid" })}, inputSecrets: []runtime.Object{validBindUserSecret("4242")}, setupMocks: func(conn *mockldapconn.MockConn) { @@ -685,7 +697,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{ { - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-upstream", Generation: 42}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "other-upstream", Generation: 42, UID: "other-uid"}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -702,7 +714,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, }, { - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -729,7 +741,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ @@ -771,7 +783,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -805,7 +817,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithStartTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -841,7 +853,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -877,7 +889,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -917,7 +929,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), @@ -954,7 +966,7 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { }, wantResultingCache: []*upstreamldap.ProviderConfig{providerConfigForValidUpstreamWithTLS}, wantResultingUpstreams: []v1alpha1.LDAPIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testResourceUID}, Status: v1alpha1.LDAPIdentityProviderStatus{ Phase: "Ready", Conditions: allConditionsTrue(1234, "4242"), diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index f7b52325..0e4399ea 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -12,10 +12,11 @@ import ( "fmt" "net/http" "net/url" - "sort" "strings" "time" + "k8s.io/apimachinery/pkg/util/sets" + "github.com/coreos/go-oidc/v3/oidc" "github.com/go-logr/logr" "golang.org/x/oauth2" @@ -52,16 +53,41 @@ const ( oidcValidatorCacheTTL = 15 * time.Minute // Constants related to conditions. - typeClientCredentialsValid = "ClientCredentialsValid" - typeOIDCDiscoverySucceeded = "OIDCDiscoverySucceeded" + typeClientCredentialsValid = "ClientCredentialsValid" + typeAdditionalAuthorizeParametersValid = "AdditionalAuthorizeParametersValid" + typeOIDCDiscoverySucceeded = "OIDCDiscoverySucceeded" - reasonUnreachable = "Unreachable" - reasonInvalidResponse = "InvalidResponse" + reasonUnreachable = "Unreachable" + reasonInvalidResponse = "InvalidResponse" + reasonDisallowedParameterName = "DisallowedParameterName" + allParamNamesAllowedMsg = "additionalAuthorizeParameters parameter names are allowed" // Errors that are generated by our reconcile process. errOIDCFailureStatus = constable.Error("OIDCIdentityProvider has a failing condition") ) +var ( + disallowedAdditionalAuthorizeParameters = map[string]bool{ //nolint: gochecknoglobals + // Reject these AdditionalAuthorizeParameters to avoid allowing the user's config to overwrite the parameters + // that are always used by Pinniped in authcode authorization requests. The OIDC library used would otherwise + // happily treat the user's config as an override. Users can already set the "client_id" and "scope" params + // using other settings, and the others never make sense to override. This map should be treated as read-only + // since it is a global variable. + "response_type": true, + "scope": true, + "client_id": true, + "state": true, + "nonce": true, + "code_challenge": true, + "code_challenge_method": true, + "redirect_uri": true, + + // Reject "hd" for now because it is not safe to use with Google's OIDC provider until Pinniped also + // performs the corresponding validation on the ID token. + "hd": true, + } +) + // UpstreamOIDCIdentityProviderICache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. type UpstreamOIDCIdentityProviderICache interface { SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI) @@ -167,19 +193,51 @@ func (c *oidcWatcherController) Sync(ctx controllerlib.Context) error { // validateUpstream validates the provided v1alpha1.OIDCIdentityProvider and returns the validated configuration as a // provider.UpstreamOIDCIdentityProvider. As a side effect, it also updates the status of the v1alpha1.OIDCIdentityProvider. func (c *oidcWatcherController) validateUpstream(ctx controllerlib.Context, upstream *v1alpha1.OIDCIdentityProvider) *upstreamoidc.ProviderConfig { + authorizationConfig := upstream.Spec.AuthorizationConfig + + additionalAuthcodeAuthorizeParameters := map[string]string{} + var rejectedAuthcodeAuthorizeParameters []string + for _, p := range authorizationConfig.AdditionalAuthorizeParameters { + if disallowedAdditionalAuthorizeParameters[p.Name] { + rejectedAuthcodeAuthorizeParameters = append(rejectedAuthcodeAuthorizeParameters, p.Name) + } else { + additionalAuthcodeAuthorizeParameters[p.Name] = p.Value + } + } + result := upstreamoidc.ProviderConfig{ Name: upstream.Name, Config: &oauth2.Config{ - Scopes: computeScopes(upstream.Spec.AuthorizationConfig.AdditionalScopes), + Scopes: computeScopes(authorizationConfig.AdditionalScopes), }, - UsernameClaim: upstream.Spec.Claims.Username, - GroupsClaim: upstream.Spec.Claims.Groups, - AllowPasswordGrant: upstream.Spec.AuthorizationConfig.AllowPasswordGrant, + UsernameClaim: upstream.Spec.Claims.Username, + GroupsClaim: upstream.Spec.Claims.Groups, + AllowPasswordGrant: authorizationConfig.AllowPasswordGrant, + AdditionalAuthcodeParams: additionalAuthcodeAuthorizeParameters, + ResourceUID: upstream.UID, } + conditions := []*v1alpha1.Condition{ c.validateSecret(upstream, &result), c.validateIssuer(ctx.Context, upstream, &result), } + if len(rejectedAuthcodeAuthorizeParameters) > 0 { + conditions = append(conditions, &v1alpha1.Condition{ + Type: typeAdditionalAuthorizeParametersValid, + Status: v1alpha1.ConditionFalse, + Reason: reasonDisallowedParameterName, + Message: fmt.Sprintf("the following additionalAuthorizeParameters are not allowed: %s", + strings.Join(rejectedAuthcodeAuthorizeParameters, ",")), + }) + } else { + conditions = append(conditions, &v1alpha1.Condition{ + Type: typeAdditionalAuthorizeParametersValid, + Status: v1alpha1.ConditionTrue, + Reason: upstreamwatchers.ReasonSuccess, + Message: allParamNamesAllowedMsg, + }) + } + c.updateStatus(ctx.Context, upstream, conditions) valid := true @@ -371,20 +429,20 @@ func getTLSConfig(upstream *v1alpha1.OIDCIdentityProvider) (*tls.Config, error) } func computeScopes(additionalScopes []string) []string { - // First compute the unique set of scopes, including "openid" (de-duplicate). - set := make(map[string]bool, len(additionalScopes)+1) - set["openid"] = true - for _, s := range additionalScopes { - set[s] = true + // If none are set then provide a reasonable default which only tries to use scopes defined in the OIDC spec. + if len(additionalScopes) == 0 { + return []string{"openid", "offline_access", "email", "profile"} } - // Then grab all the keys and sort them. - scopes := make([]string, 0, len(set)) - for s := range set { - scopes = append(scopes, s) + // Otherwise, first compute the unique set of scopes, including "openid" (de-duplicate). + set := sets.NewString() + set.Insert("openid") + for _, s := range additionalScopes { + set.Insert(s) } - sort.Strings(scopes) - return scopes + + // Return the set as a sorted list. + return set.List() } func truncateMostLongErr(err error) string { diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go index fd1a864d..57942c02 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go @@ -18,6 +18,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" @@ -118,17 +119,31 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { require.NoError(t, err) wrongCABase64 := base64.StdEncoding.EncodeToString(wrongCA.Bundle()) + happyAdditionalAuthorizeParametersValidCondition := v1alpha1.Condition{ + Type: "AdditionalAuthorizeParametersValid", + Status: "True", + Reason: "Success", + Message: "additionalAuthorizeParameters parameter names are allowed", + LastTransitionTime: now, + } + happyAdditionalAuthorizeParametersValidConditionEarlier := happyAdditionalAuthorizeParametersValidCondition + happyAdditionalAuthorizeParametersValidConditionEarlier.LastTransitionTime = earlier + var ( - testNamespace = "test-namespace" - testName = "test-name" - testSecretName = "test-client-secret" - testAdditionalScopes = []string{"scope1", "scope2", "scope3"} - testExpectedScopes = []string{"openid", "scope1", "scope2", "scope3"} - testClientID = "test-oidc-client-id" - testClientSecret = "test-oidc-client-secret" - testValidSecretData = map[string][]byte{"clientID": []byte(testClientID), "clientSecret": []byte(testClientSecret)} - testGroupsClaim = "test-groups-claim" - testUsernameClaim = "test-username-claim" + testNamespace = "test-namespace" + testName = "test-name" + testSecretName = "test-client-secret" + testAdditionalScopes = []string{"scope1", "scope2", "scope3"} + testExpectedScopes = []string{"openid", "scope1", "scope2", "scope3"} + testDefaultExpectedScopes = []string{"openid", "offline_access", "email", "profile"} + testAdditionalParams = []v1alpha1.Parameter{{Name: "prompt", Value: "consent"}, {Name: "foo", Value: "bar"}} + testExpectedAdditionalParams = map[string]string{"prompt": "consent", "foo": "bar"} + testClientID = "test-oidc-client-id" + testClientSecret = "test-oidc-client-secret" + testValidSecretData = map[string][]byte{"clientID": []byte(testClientID), "clientSecret": []byte(testClientSecret)} + testGroupsClaim = "test-groups-claim" + testUsernameClaim = "test-username-claim" + testUID = types.UID("test-uid") ) tests := []struct { name string @@ -147,10 +162,9 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL, - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{}, @@ -158,6 +172,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="secret \"test-client-secret\" not found" "reason"="SecretNotFound" "status"="False" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="secret \"test-client-secret\" not found" "name"="test-name" "namespace"="test-namespace" "reason"="SecretNotFound" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -166,6 +181,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "False", @@ -189,10 +205,9 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL, - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -204,6 +219,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "reason"="SecretWrongType" "status"="False" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" has wrong type \"some-other-type\" (should be \"secrets.pinniped.dev/oidc-client\")" "name"="test-name" "namespace"="test-namespace" "reason"="SecretWrongType" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -212,6 +228,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "False", @@ -235,10 +252,9 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL, - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -249,6 +265,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "reason"="SecretMissingKeys" "status"="False" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="referenced Secret \"test-client-secret\" is missing required keys [\"clientID\" \"clientSecret\"]" "name"="test-name" "namespace"="test-namespace" "reason"="SecretMissingKeys" "type"="ClientCredentialsValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -257,6 +274,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "False", @@ -284,8 +302,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { TLS: &v1alpha1.TLSSpec{ CertificateAuthorityData: "invalid-base64", }, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -297,6 +314,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: illegal base64 data at input byte 7" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -305,6 +323,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -332,8 +351,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { TLS: &v1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("not-a-pem-ca-bundle")), }, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: append(testAdditionalScopes, "xyz", "openid")}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -345,6 +363,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="spec.certificateAuthorityData is invalid: no certificates found" "reason"="InvalidTLSConfig" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="spec.certificateAuthorityData is invalid: no certificates found" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidTLSConfig" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -353,6 +372,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -376,9 +396,8 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -391,6 +410,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol scheme \"\"" "issuer"="invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "name"="test-name" "namespace"="test-namespace"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"invalid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": unsupported protocol [truncated 9 chars]" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -399,6 +419,7 @@ func TestOIDCUpstreamWatcherControllerSync(t *testing.T) { Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -423,10 +444,9 @@ Get "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-na inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: wrongCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL + "/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: wrongCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -439,6 +459,7 @@ Get "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-na `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="Get \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": x509: certificate signed by unknown authority" "issuer"="` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "name"="test-name" "namespace"="test-namespace"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": x509: certificate signed by unknown authority" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\":\nGet \"` + testIssuerURL + `/valid-url-that-is-really-really-long-nanananananananannanananan-batman-nanananananananananananananana-batman-lalalalalalalalalal-batman-weeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee/.well-known/openid-configuration\": x509: certificate signed by unknown authority" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -447,6 +468,7 @@ Get "invalid-url-that-is-really-really-long-nanananananananannanananan-batman-na Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -471,10 +493,9 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/invalid", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL + "/invalid", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -486,6 +507,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to parse authorization endpoint URL: parse \"%\": invalid URL escape \"%\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -494,6 +516,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -517,10 +540,9 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/insecure", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL + "/insecure", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -532,6 +554,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "reason"="InvalidResponse" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="authorization endpoint URL scheme must be \"https\", not \"http\"" "name"="test-name" "namespace"="test-namespace" "reason"="InvalidResponse" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -540,6 +563,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -561,13 +585,13 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana { name: "upstream with error becomes valid", inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name"}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "test-name", UID: testUID}, Spec: v1alpha1.OIDCIdentityProviderSpec{ Issuer: testIssuerURL, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ - AdditionalScopes: append(testAdditionalScopes, "xyz", "openid"), + AdditionalScopes: append(testAdditionalScopes, "xyz", "openid"), // adds openid unnecessarily AllowPasswordGrant: true, }, Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, @@ -588,23 +612,27 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: append(testExpectedScopes, "xyz"), - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, - AllowPasswordGrant: true, + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: append(testExpectedScopes, "xyz"), // includes openid only once + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: true, + AdditionalAuthcodeParams: map[string]string{}, + ResourceUID: testUID, }, }, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, UID: testUID}, Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials"}, {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "discovered issuer configuration"}, }, @@ -612,22 +640,135 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana }}, }, { - name: "existing valid upstream", + name: "existing valid upstream with default authorizationConfig", inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Spec: v1alpha1.OIDCIdentityProviderSpec{ Issuer: testIssuerURL, TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, + }, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidConditionEarlier, + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"}, + }, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantLogs: []string{ + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ + &oidctestutil.TestUpstreamOIDCIdentityProvider{ + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testDefaultExpectedScopes, + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: false, + AdditionalAuthcodeParams: map[string]string{}, + ResourceUID: testUID, + }, + }, + wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + {Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234}, + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, + }, + }, + }}, + }, + { + name: "existing valid upstream with additionalScopes set to override the default", + inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, + Spec: v1alpha1.OIDCIdentityProviderSpec{ + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ - AdditionalScopes: testAdditionalScopes, - AllowPasswordGrant: false, + AdditionalScopes: testAdditionalScopes, + }, + }, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidConditionEarlier, + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"}, + }, + }, + }}, + inputSecrets: []runtime.Object{&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testSecretName}, + Type: "secrets.pinniped.dev/oidc-client", + Data: testValidSecretData, + }}, + wantLogs: []string{ + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, + }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ + &oidctestutil.TestUpstreamOIDCIdentityProvider{ + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testExpectedScopes, + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: false, + AdditionalAuthcodeParams: map[string]string{}, + ResourceUID: testUID, + }, + }, + wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, + Status: v1alpha1.OIDCIdentityProviderStatus{ + Phase: "Ready", + Conditions: []v1alpha1.Condition{ + {Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234}, + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, + }, + }, + }}, + }, + { + name: "existing valid upstream with trailing slash and more optional settings", + inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, + Spec: v1alpha1.OIDCIdentityProviderSpec{ + Issuer: testIssuerURL + "/ends-with-slash/", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: testAdditionalScopes, + AdditionalAuthorizeParameters: testAdditionalParams, + AllowPasswordGrant: true, }, Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, }, Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidConditionEarlier, {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"}, {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"}, }, @@ -641,23 +782,27 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: testExpectedScopes, - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, - AllowPasswordGrant: false, + Name: testName, + ClientID: testClientID, + AuthorizationURL: *testIssuerAuthorizeURL, + Scopes: testExpectedScopes, // does not include the default scopes + UsernameClaim: testUsernameClaim, + GroupsClaim: testGroupsClaim, + AllowPasswordGrant: true, + AdditionalAuthcodeParams: testExpectedAdditionalParams, + ResourceUID: testUID, }, }, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Ready", Conditions: []v1alpha1.Condition{ + {Type: "AdditionalAuthorizeParametersValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "additionalAuthorizeParameters parameter names are allowed", ObservedGeneration: 1234}, {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, }, @@ -665,21 +810,26 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana }}, }, { - name: "existing valid upstream with trailing slash", + name: "has disallowed additionalAuthorizeParams keys", inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/ends-with-slash/", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, - Claims: v1alpha1.OIDCClaims{Groups: testGroupsClaim, Username: testUsernameClaim}, - }, - Status: v1alpha1.OIDCIdentityProviderStatus{ - Phase: "Ready", - Conditions: []v1alpha1.Condition{ - {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials"}, - {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration"}, + Issuer: testIssuerURL, + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, + AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{ + AdditionalAuthorizeParameters: []v1alpha1.Parameter{ + {Name: "response_type", Value: "foo"}, + {Name: "scope", Value: "foo"}, + {Name: "client_id", Value: "foo"}, + {Name: "state", Value: "foo"}, + {Name: "nonce", Value: "foo"}, + {Name: "code_challenge", Value: "foo"}, + {Name: "code_challenge_method", Value: "foo"}, + {Name: "redirect_uri", Value: "foo"}, + {Name: "hd", Value: "foo"}, + {Name: "this_one_is_allowed", Value: "foo"}, + }, }, }, }}, @@ -688,41 +838,36 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana Type: "secrets.pinniped.dev/oidc-client", Data: testValidSecretData, }}, + wantErr: controllerlib.ErrSyntheticRequeue.Error(), wantLogs: []string{ `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="discovered issuer configuration" "reason"="Success" "status"="True" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="the following additionalAuthorizeParameters are not allowed: response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd" "reason"="DisallowedParameterName" "status"="False" "type"="AdditionalAuthorizeParametersValid"`, + `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="the following additionalAuthorizeParameters are not allowed: response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd" "name"="test-name" "namespace"="test-namespace" "reason"="DisallowedParameterName" "type"="AdditionalAuthorizeParametersValid"`, }, - wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{ - &oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: testName, - ClientID: testClientID, - AuthorizationURL: *testIssuerAuthorizeURL, - Scopes: testExpectedScopes, - UsernameClaim: testUsernameClaim, - GroupsClaim: testGroupsClaim, - AllowPasswordGrant: false, - }, - }, + wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, wantResultingUpstreams: []v1alpha1.OIDCIdentityProvider{{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234}, + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: v1alpha1.OIDCIdentityProviderStatus{ - Phase: "Ready", + Phase: "Error", Conditions: []v1alpha1.Condition{ - {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, - {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: earlier, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, + {Type: "AdditionalAuthorizeParametersValid", Status: "False", LastTransitionTime: now, Reason: "DisallowedParameterName", + Message: "the following additionalAuthorizeParameters are not allowed: " + + "response_type,scope,client_id,state,nonce,code_challenge,code_challenge_method,redirect_uri,hd", ObservedGeneration: 1234}, + {Type: "ClientCredentialsValid", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "loaded client credentials", ObservedGeneration: 1234}, + {Type: "OIDCDiscoverySucceeded", Status: "True", LastTransitionTime: now, Reason: "Success", Message: "discovered issuer configuration", ObservedGeneration: 1234}, }, }, }}, }, { - name: "issuer is invalid URL, missing trailing slash", + name: "issuer is invalid URL, missing trailing slash when the OIDC discovery endpoint returns the URL with a trailing slash", inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/ends-with-slash", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL + "/ends-with-slash", // this does not end with slash when it should, thus this is an error case + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -735,6 +880,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "issuer"="` + testIssuerURL + `/ends-with-slash" "name"="test-name" "namespace"="test-namespace"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/ends-with-slash\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/ends-with-slash\" got \"` + testIssuerURL + `/ends-with-slash/\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -743,6 +889,7 @@ Get "` + testIssuerURL + `/valid-url-that-is-really-really-long-nananananananana Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -767,10 +914,9 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs inputUpstreams: []runtime.Object{&v1alpha1.OIDCIdentityProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName}, Spec: v1alpha1.OIDCIdentityProviderSpec{ - Issuer: testIssuerURL + "/", - TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, - Client: v1alpha1.OIDCClient{SecretName: testSecretName}, - AuthorizationConfig: v1alpha1.OIDCAuthorizationConfig{AdditionalScopes: testAdditionalScopes}, + Issuer: testIssuerURL + "/", + TLS: &v1alpha1.TLSSpec{CertificateAuthorityData: testIssuerCABase64}, + Client: v1alpha1.OIDCClient{SecretName: testSecretName}, }, }}, inputSecrets: []runtime.Object{&corev1.Secret{ @@ -783,6 +929,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs `oidc-upstream-observer "msg"="failed to perform OIDC discovery" "error"="oidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "issuer"="` + testIssuerURL + `/" "name"="test-name" "namespace"="test-namespace"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="loaded client credentials" "reason"="Success" "status"="True" "type"="ClientCredentialsValid"`, `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "reason"="Unreachable" "status"="False" "type"="OIDCDiscoverySucceeded"`, + `oidc-upstream-observer "level"=0 "msg"="updated condition" "name"="test-name" "namespace"="test-namespace" "message"="additionalAuthorizeParameters parameter names are allowed" "reason"="Success" "status"="True" "type"="AdditionalAuthorizeParametersValid"`, `oidc-upstream-observer "msg"="found failing condition" "error"="OIDCIdentityProvider has a failing condition" "message"="failed to perform OIDC discovery against \"` + testIssuerURL + `/\":\noidc: issuer did not match the issuer returned by provider, expected \"` + testIssuerURL + `/\" got \"` + testIssuerURL + `\"" "name"="test-name" "namespace"="test-namespace" "reason"="Unreachable" "type"="OIDCDiscoverySucceeded"`, }, wantResultingCache: []provider.UpstreamOIDCIdentityProviderI{}, @@ -791,6 +938,7 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs Status: v1alpha1.OIDCIdentityProviderStatus{ Phase: "Error", Conditions: []v1alpha1.Condition{ + happyAdditionalAuthorizeParametersValidCondition, { Type: "ClientCredentialsValid", Status: "True", @@ -860,6 +1008,8 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs require.Equal(t, tt.wantResultingCache[i].GetUsernameClaim(), actualIDP.GetUsernameClaim()) require.Equal(t, tt.wantResultingCache[i].GetGroupsClaim(), actualIDP.GetGroupsClaim()) require.Equal(t, tt.wantResultingCache[i].AllowsPasswordGrant(), actualIDP.AllowsPasswordGrant()) + require.Equal(t, tt.wantResultingCache[i].GetAdditionalAuthcodeParams(), actualIDP.GetAdditionalAuthcodeParams()) + require.Equal(t, tt.wantResultingCache[i].GetResourceUID(), actualIDP.GetResourceUID()) require.ElementsMatch(t, tt.wantResultingCache[i].GetScopes(), actualIDP.GetScopes()) // We always want to use the proxy from env on these clients, so although the following assertions diff --git a/internal/crud/crud.go b/internal/crud/crud.go index 84abe142..4211ae10 100644 --- a/internal/crud/crud.go +++ b/internal/crud/crud.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package crud @@ -109,6 +109,10 @@ func (s *secretsStorage) validateSecret(secret *corev1.Secret) error { } func (s *secretsStorage) Update(ctx context.Context, signature, resourceVersion string, data JSON) (string, error) { + // Note: There may be a small bug here in that toSecret will move the SecretLifetimeAnnotationKey date forward + // instead of keeping the storage resource's original SecretLifetimeAnnotationKey value. However, we only use + // this Update method in one place, and it doesn't matter in that place. Be aware that it might need improvement + // if we start using this Update method in more places. secret, err := s.toSecret(signature, resourceVersion, data, nil) if err != nil { return "", err diff --git a/internal/fositestorage/accesstoken/accesstoken.go b/internal/fositestorage/accesstoken/accesstoken.go index b4427543..dbd57fb3 100644 --- a/internal/fositestorage/accesstoken/accesstoken.go +++ b/internal/fositestorage/accesstoken/accesstoken.go @@ -10,7 +10,6 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/handler/openid" "k8s.io/apimachinery/pkg/api/errors" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -18,6 +17,7 @@ import ( "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( @@ -26,7 +26,9 @@ const ( ErrInvalidAccessTokenRequestVersion = constable.Error("access token request data has wrong version") ErrInvalidAccessTokenRequestData = constable.Error("access token request data must be present") - accessTokenStorageVersion = "1" + // Version 1 was the initial release of storage. + // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. + accessTokenStorageVersion = "2" ) type RevocationStorage interface { @@ -110,7 +112,7 @@ func newValidEmptyAccessTokenSession() *session { return &session{ Request: &fosite.Request{ Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, }, } } diff --git a/internal/fositestorage/accesstoken/accesstoken_test.go b/internal/fositestorage/accesstoken/accesstoken_test.go index 3c5b22df..dc468742 100644 --- a/internal/fositestorage/accesstoken/accesstoken_test.go +++ b/internal/fositestorage/accesstoken/accesstoken_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/pkg/errors" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -22,6 +21,8 @@ import ( coretesting "k8s.io/client-go/testing" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" ) const namespace = "test-ns" @@ -51,7 +52,7 @@ func TestAccessTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -84,16 +85,10 @@ func TestAccessTokenStorage(t *testing.T) { RequestObjectSigningAlgorithm: "", TokenEndpointAuthSigningAlgorithm: "", }}, - RequestedScope: nil, - GrantedScope: nil, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Claims: nil, - Headers: nil, - ExpiresAt: nil, - Username: "snorlax", - Subject: "panda", - }, + RequestedScope: nil, + GrantedScope: nil, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), RequestedAudience: nil, GrantedAudience: nil, } @@ -107,6 +102,7 @@ func TestAccessTokenStorage(t *testing.T) { err = storage.DeleteAccessTokenSession(ctx, "fancy-signature") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -125,7 +121,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -151,11 +147,8 @@ func TestAccessTokenStorageRevocation(t *testing.T) { TokenEndpointAuthMethod: "something", }, }, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Username: "snorlax", - Subject: "panda", - }, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), } err := storage.CreateAccessTokenSession(ctx, "fancy-signature", request) require.NoError(t, err) @@ -164,6 +157,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) { err = storage.RevokeAccessToken(ctx, "abcd-1") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -190,7 +184,7 @@ func TestWrongVersion(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"not-the-right-version"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -200,7 +194,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 1") + require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 2") } func TestNilSessionRequest(t *testing.T) { @@ -218,7 +212,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -246,10 +240,10 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) { Client: &clientregistry.Client{}, } err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request) - require.EqualError(t, err, "requester's session must be of type openid.DefaultSession") + require.EqualError(t, err, "requester's session must be of type PinnipedSession") request = &fosite.Request{ - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: nil, } err = storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request) @@ -261,7 +255,7 @@ func TestCreateWithoutRequesterID(t *testing.T) { request := &fosite.Request{ ID: "", // empty ID - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: &clientregistry.Client{}, } err := storage.CreateAccessTokenSession(ctx, "signature-doesnt-matter", request) diff --git a/internal/fositestorage/authorizationcode/authorizationcode.go b/internal/fositestorage/authorizationcode/authorizationcode.go index f66c193c..b259e406 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode.go +++ b/internal/fositestorage/authorizationcode/authorizationcode.go @@ -11,7 +11,6 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/handler/openid" "k8s.io/apimachinery/pkg/api/errors" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -19,6 +18,7 @@ import ( "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( @@ -27,7 +27,9 @@ const ( ErrInvalidAuthorizeRequestData = constable.Error("authorization request data must be present") ErrInvalidAuthorizeRequestVersion = constable.Error("authorization request data has wrong version") - authorizeCodeStorageVersion = "1" + // Version 1 was the initial release of storage. + // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. + authorizeCodeStorageVersion = "2" ) var _ oauth2.AuthorizeCodeStorage = &authorizeCodeStorage{} @@ -139,7 +141,7 @@ func NewValidEmptyAuthorizeCodeSession() *AuthorizeCodeSession { return &AuthorizeCodeSession{ Request: &fosite.Request{ Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, }, } } @@ -169,161 +171,172 @@ func (e *errSerializationFailureWithCause) Error() string { // ExpectedAuthorizeCodeSessionJSONFromFuzzing is used for round tripping tests. // It is exported to allow integration tests to use it. const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{ - "active": true, - "request": { + "active": true, + "request": { "id": "曑x螠Gæ鄋楨", "requestedAt": "2082-11-10T18:36:11.627253638Z", "client": { - "id": ":NJ¸Ɣ8(黋馛ÄRɴJa¶z", - "client_secret": "UQ==", - "redirect_uris": [ - "ǖ枭kʍ切厦ȳ箦;¥ʊXĝ奨誷傥祩d", - "zŇZ", - "優蒼ĊɌț訫DŽǽeʀO2ƚ&N" - ], - "grant_types": [ - "唐W6ɻ橩斚薛ɑƐ" - ], - "response_types": [ - "w", - "ǔŭe[u@阽羂ŷ-Ĵ½輢OÅ濲喾H" - ], - "scopes": [ - "G螩歐湡ƙı唡ɸğƎ&胢輢Ƈĵƚ" - ], - "audience": [ - "ě" - ], - "public": false, - "jwks_uri": "o*泞羅ʘ Ⱦķ瀊垰7ã\")", - "jwks": { - "keys": [ - { - "kty": "OKP", - "crv": "Ed25519", - "x": "nK9xgX_iN7u3u_i8YOO7ZRT_WK028Vd_nhtsUu7Eo6E", - "x5u": { - "Scheme": "", - "Opaque": "", - "User": null, - "Host": "", - "Path": "", - "RawPath": "", - "ForceQuery": false, - "RawQuery": "", - "Fragment": "", - "RawFragment": "" - } - }, - { - "kty": "OKP", - "crv": "Ed25519", - "x": "UbbswQgzWhfGCRlwQmMp6fw_HoIoqkIaKT-2XN2fuYU", - "x5u": { - "Scheme": "", - "Opaque": "", - "User": null, - "Host": "", - "Path": "", - "RawPath": "", - "ForceQuery": false, - "RawQuery": "", - "Fragment": "", - "RawFragment": "" - } - } - ] - }, - "token_endpoint_auth_method": "ƿʥǟȒ伉Q鱙翑ȲŻ", - "Issuer": "锰劝旣樎Ȱ鍌#ȳńƩŴȭ", - "Subject": "绝TFNJĆw宵ɚeY48珎²", - "Audience": [ - "éã越|j¦鲶H股ƲLŋZ-{5£踉4" - ], - "Nonce": "5^驜Ŗ~ů崧軒q腟u尿", - "ExpiresAt": "2065-11-30T13:47:03.613000626Z", - "IssuedAt": "1976-02-22T09:57:20.479850437Z", - "RequestedAt": "2016-04-13T04:18:53.648949323Z", - "AuthTime": "2098-07-12T04:38:54.034043015Z", - "AccessTokenHash": "嫯R", - "AuthenticationContextClassReference": "¤'+ʣ", - "AuthenticationMethodsReference": "L&ɽ艄ʬʏ", - "CodeHash": "ğǫ\\aȊ4ț髄Al", - "Extra": { - "PƢ曰": { - "ĸŴB岺Ð嫹Sx镯荫ő": [ - 843216989 - ], - "疂ư墫ɓ": { - "\\BRë_g\"ʎ啴SƇMǃļ": { - "ʦ4": false - }, - "鶡萷ɵ啜s攦": null - } - }, - "曓蓳n匟鯘磹*金爃鶴滱ůĮǐ_c3#": 2520197933 - } - }, - "Headers": { - "Extra": { - "寱ĊƑ÷Ƒ螞费Ďğ~劰û橸ɽ銐ƭ?}": { - "ȜʁɁ;Bd謺錳4帳ŅǃĊd": { - "翢砜Fȏl鐉诳DT=3骜": { - "ų厷ɁOƪ穋嶿鳈恱va|载ǰɱ汶C": false - }, - "鸨EJ毕懴řĬń戹%c": null + "fosite": { + "Claims": { + "JTI": "u妔隤ʑƍš駎竪0ɔ闏À1", + "Issuer": "麤ã桒嘞\\摗Ǘū稖咾鎅ǸÖ绝TF", + "Subject": "巽ēđų蓼tùZ蛆鬣a\"ÙǞ0觢Û±", + "Audience": [ + "H股ƲL", + "肟v\u0026đehpƧ", + "5^驜Ŗ~ů崧軒q腟u尿" + ], + "Nonce": "ğ", + "ExpiresAt": "2016-11-22T21:33:58.460521133Z", + "IssuedAt": "1990-07-25T23:42:07.055978334Z", + "RequestedAt": "1971-01-30T00:23:36.377684025Z", + "AuthTime": "2088-11-09T12:09:14.051840239Z", + "AccessTokenHash": "蕖¤'+ʣȍ瓁U4鞀", + "AuthenticationContextClassReference": "ʏÑęN\u003c_z", + "AuthenticationMethodsReference": "ț髄A", + "CodeHash": "4磔_袻vÓG-壧丵礴鋈k蟵pAɂʅ", + "Extra": { + "#\u0026PƢ曰l騌蘙螤\\阏Đ镴Ƥm蔻ǭ\\鿞": 1677215584, + "Y\u0026鶡萷ɵ啜s攦Ɩïdnǔ": { + ",t猟i\u0026\u0026Q@ǤǟǗǪ飘ȱF?Ƈ": { + "~劰û橸ɽ銐ƭ?}H": null, + "癑勦e骲v0H晦XŘO溪V蔓": { + "碼Ǫ": false + } + }, + "钻煐ɨəÅDČ{Ȩʦ4撎": [ + 3684968178 + ] + } + } }, - "室癑勦e骲v0H晦XŘO溪V蔓Ȍ+~ē": [ - 954647573 - ] - }, - "麈ƵDǀ\\郂üţ垂": 1572524915 + "Headers": { + "Extra": { + "ĊdŘ鸨EJ毕懴řĬń戹": { + "诳DT=3骜Ǹ,": { + "\u003e": { + "ǰ": false + }, + "ɁOƪ穋嶿鳈恱va": null + }, + "豑觳翢砜Fȏl": [ + 927958776 + ] + }, + "埅ȜʁɁ;Bd謺錳4帳Ņ": 388005986 + } + }, + "ExpiresAt": { + "C]ɲ'=ĸ闒NȢȰ.醋": "1970-07-19T18:03:29.902062193Z", + "fɤȆʪ融ƆuŤn": "2064-01-24T20:34:16.593152073Z", + "爣縗ɦüHêQ仏1ő": "2102-03-17T06:24:40.256846902Z" + }, + "Username": "韁臯氃妪婝rȤ\"h丬鎒ơ娻}ɼƟ", + "Subject": "闺髉龳ǽÙ龦O亾EW莛8嘶×" + }, + "custom": { + "providerUID": "鵮碡ʯiŬŽ非Ĝ眧Ĭ葜SŦ餧Ĭ倏4", + "providerName": "nŐǛ3", + "providerType": "闣ʬ橳(ý綃ʃʚƟ覣k眐4Ĉt", + "oidc": { + "upstreamRefreshToken": "嵽痊w©Ź榨Q|ôɵt毇妬" + } } - }, - "ExpiresAt": { - "'=ĸ闒NȢȰ.醋fʜ": "2031-10-18T22:07:34.950803105Z", - "ɦüHêQ仏1őƖ2Ė暮唍ǞʜƢú4": "2049-05-13T15:27:20.968432454Z" - }, - "Username": "+韁臯氃妪婝rȤ\"h丬鎒ơ娻}ɼƟȥE", - "Subject": "龳ǽÙ龦O亾EW莛8嘶×姮c恭企" }, "requestedAudience": [ - "邖ɐ5檄¬", - "Ĭ葜SŦ餧Ĭ倏4ĵ嶼仒篻ɥ闣ʬ橳(ý綃" + "6鉢緋uƴŤȱʀļÂ?墖\u003cƬb獭潜Ʃ饾" ], "grantedAudience": [ - "ʚƟ覣k眐4ĈtC嵽痊w©Ź榨Q|ô", - "猊Ia瓕巈環_ɑ彨ƍ蛊ʚ£:設虝2" + "|鬌R蜚蠣麹概÷驣7Ʀ澉1æɽ誮rʨ鷞" ] - }, - "version": "1" - }` + }, + "version": "2" +}` diff --git a/internal/fositestorage/authorizationcode/authorizationcode_test.go b/internal/fositestorage/authorizationcode/authorizationcode_test.go index b93481c9..50ac0c28 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode_test.go +++ b/internal/fositestorage/authorizationcode/authorizationcode_test.go @@ -18,7 +18,6 @@ import ( fuzz "github.com/google/gofuzz" "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/handler/openid" "github.com/pkg/errors" "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" @@ -27,6 +26,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/clock" "k8s.io/client-go/kubernetes/fake" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -34,6 +34,8 @@ import ( "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" ) const namespace = "test-ns" @@ -62,7 +64,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -81,7 +83,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -113,16 +115,10 @@ func TestAuthorizationCodeStorage(t *testing.T) { TokenEndpointAuthSigningAlgorithm: "", }, }, - RequestedScope: nil, - GrantedScope: nil, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Claims: nil, - Headers: nil, - ExpiresAt: nil, - Username: "snorlax", - Subject: "panda", - }, + RequestedScope: nil, + GrantedScope: nil, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), RequestedAudience: nil, GrantedAudience: nil, } @@ -136,6 +132,8 @@ func TestAuthorizationCodeStorage(t *testing.T) { err = storage.InvalidateAuthorizeCodeSession(ctx, "fancy-signature") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed + testutil.LogActualJSONFromUpdateAction(t, client, 3) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) // Doing a Get on an invalidated session should still return the session, but also return an error. @@ -173,7 +171,7 @@ func TestInvalidateWhenConflictOnUpdateHappens(t *testing.T) { request := &fosite.Request{ ID: "some-request-id", Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: testutil.NewFakePinnipedSession(), } err := storage.CreateAuthorizeCodeSession(ctx, "fancy-signature", request) require.NoError(t, err) @@ -193,7 +191,7 @@ func TestWrongVersion(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version", "active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"not-the-right-version","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -203,7 +201,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAuthorizeCodeSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 1") + require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 2") } func TestNilSessionRequest(t *testing.T) { @@ -218,7 +216,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"1", "active": true}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"2", "active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -246,10 +244,10 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) { Client: &clientregistry.Client{}, } err := storage.CreateAuthorizeCodeSession(ctx, "signature-doesnt-matter", request) - require.EqualError(t, err, "requester's session must be of type openid.DefaultSession") + require.EqualError(t, err, "requester's session must be of type PinnipedSession") request = &fosite.Request{ - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: nil, } err = storage.CreateAuthorizeCodeSession(ctx, "signature-doesnt-matter", request) @@ -274,7 +272,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { // checked above defaultClient := validSession.Request.Client.(*clientregistry.Client) - defaultSession := validSession.Request.Session.(*openid.DefaultSession) + pinnipedSession := validSession.Request.Session.(*psession.PinnipedSession) // makes it easier to use a raw string replacer := strings.NewReplacer("`", "a") @@ -297,12 +295,12 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { *fc = defaultClient }, func(fs *fosite.Session, c fuzz.Continue) { - c.Fuzz(defaultSession) - *fs = defaultSession + c.Fuzz(pinnipedSession) + *fs = pinnipedSession }, // these types contain an interface{} that we need to handle - // this is safe because we explicitly provide the openid.DefaultSession concrete type + // this is safe because we explicitly provide the PinnipedSession concrete type func(value *map[string]interface{}, c fuzz.Continue) { // cover all the JSON data types just in case *value = map[string]interface{}{ @@ -346,6 +344,9 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { func(s *fosite.TokenType, c fuzz.Continue) { *s = fosite.TokenType(randString(c)) }, + func(s *types.UID, c fuzz.Continue) { + *s = types.UID(randString(c)) + }, // handle string type alias func(s *fosite.Arguments, c fuzz.Continue) { n := c.Intn(3) + 1 // 1 to 3 items @@ -382,7 +383,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { // set these to match CreateAuthorizeCodeSession so that .JSONEq works validSession.Active = true - validSession.Version = "1" + validSession.Version = "2" validSessionJSONBytes, err := json.MarshalIndent(validSession, "", "\t") require.NoError(t, err) diff --git a/internal/fositestorage/fositestorage.go b/internal/fositestorage/fositestorage.go index bf1c20e9..d3c8f476 100644 --- a/internal/fositestorage/fositestorage.go +++ b/internal/fositestorage/fositestorage.go @@ -5,16 +5,16 @@ package fositestorage import ( "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( ErrInvalidRequestType = constable.Error("requester must be of type fosite.Request") ErrInvalidClientType = constable.Error("requester's client must be of type clientregistry.Client") - ErrInvalidSessionType = constable.Error("requester's session must be of type openid.DefaultSession") + ErrInvalidSessionType = constable.Error("requester's session must be of type PinnipedSession") StorageRequestIDLabelName = "storage.pinniped.dev/request-id" //nolint:gosec // this is not a credential ) @@ -27,7 +27,7 @@ func ValidateAndExtractAuthorizeRequest(requester fosite.Requester) (*fosite.Req if !ok2 { return nil, ErrInvalidClientType } - _, ok3 := request.Session.(*openid.DefaultSession) + _, ok3 := request.Session.(*psession.PinnipedSession) if !ok3 { return nil, ErrInvalidSessionType } diff --git a/internal/fositestorage/openidconnect/openidconnect.go b/internal/fositestorage/openidconnect/openidconnect.go index f747a667..81699410 100644 --- a/internal/fositestorage/openidconnect/openidconnect.go +++ b/internal/fositestorage/openidconnect/openidconnect.go @@ -18,6 +18,7 @@ import ( "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( @@ -27,7 +28,9 @@ const ( ErrInvalidOIDCRequestData = constable.Error("oidc request data must be present") ErrMalformedAuthorizationCode = constable.Error("malformed authorization code") - oidcStorageVersion = "1" + // Version 1 was the initial release of storage. + // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. + oidcStorageVersion = "2" ) var _ openid.OpenIDConnectRequestStorage = &openIDConnectRequestStorage{} @@ -112,7 +115,7 @@ func newValidEmptyOIDCSession() *session { return &session{ Request: &fosite.Request{ Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, }, } } diff --git a/internal/fositestorage/openidconnect/openidconnect_test.go b/internal/fositestorage/openidconnect/openidconnect_test.go index 6328ffa2..ba76fe8e 100644 --- a/internal/fositestorage/openidconnect/openidconnect_test.go +++ b/internal/fositestorage/openidconnect/openidconnect_test.go @@ -22,6 +22,8 @@ import ( coretesting "k8s.io/client-go/testing" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" ) const namespace = "test-ns" @@ -50,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", @@ -84,16 +86,10 @@ func TestOpenIdConnectStorage(t *testing.T) { TokenEndpointAuthSigningAlgorithm: "", }, }, - RequestedScope: nil, - GrantedScope: nil, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Claims: nil, - Headers: nil, - ExpiresAt: nil, - Username: "snorlax", - Subject: "panda", - }, + RequestedScope: nil, + GrantedScope: nil, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), RequestedAudience: nil, GrantedAudience: nil, } @@ -107,6 +103,7 @@ func TestOpenIdConnectStorage(t *testing.T) { err = storage.DeleteOpenIDConnectSession(ctx, "fancy-code.fancy-signature") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -130,7 +127,7 @@ func TestWrongVersion(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"not-the-right-version"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", @@ -140,7 +137,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetOpenIDConnectSession(ctx, "fancy-code.fancy-signature", nil) - require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 1") + require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 2") } func TestNilSessionRequest(t *testing.T) { @@ -155,7 +152,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", @@ -183,10 +180,10 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) { Client: &clientregistry.Client{}, } err := storage.CreateOpenIDConnectSession(ctx, "authcode.signature-doesnt-matter", request) - require.EqualError(t, err, "requester's session must be of type openid.DefaultSession") + require.EqualError(t, err, "requester's session must be of type PinnipedSession") request = &fosite.Request{ - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: nil, } err = storage.CreateOpenIDConnectSession(ctx, "authcode.signature-doesnt-matter", request) diff --git a/internal/fositestorage/pkce/pkce.go b/internal/fositestorage/pkce/pkce.go index 424a7855..cbe566bd 100644 --- a/internal/fositestorage/pkce/pkce.go +++ b/internal/fositestorage/pkce/pkce.go @@ -9,7 +9,6 @@ import ( "time" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/pkce" "k8s.io/apimachinery/pkg/api/errors" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -18,6 +17,7 @@ import ( "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( @@ -26,7 +26,9 @@ const ( ErrInvalidPKCERequestVersion = constable.Error("pkce request data has wrong version") ErrInvalidPKCERequestData = constable.Error("pkce request data must be present") - pkceStorageVersion = "1" + // Version 1 was the initial release of storage. + // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. + pkceStorageVersion = "2" ) var _ pkce.PKCERequestStorage = &pkceStorage{} @@ -96,7 +98,7 @@ func newValidEmptyPKCESession() *session { return &session{ Request: &fosite.Request{ Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, }, } } diff --git a/internal/fositestorage/pkce/pkce_test.go b/internal/fositestorage/pkce/pkce_test.go index 671797eb..d57649be 100644 --- a/internal/fositestorage/pkce/pkce_test.go +++ b/internal/fositestorage/pkce/pkce_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/pkce" "github.com/pkg/errors" "github.com/stretchr/testify/require" @@ -23,6 +22,8 @@ import ( coretesting "k8s.io/client-go/testing" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" ) const namespace = "test-ns" @@ -51,7 +52,7 @@ func TestPKCEStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", @@ -85,16 +86,10 @@ func TestPKCEStorage(t *testing.T) { TokenEndpointAuthSigningAlgorithm: "", }, }, - RequestedScope: nil, - GrantedScope: nil, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Claims: nil, - Headers: nil, - ExpiresAt: nil, - Username: "snorlax", - Subject: "panda", - }, + RequestedScope: nil, + GrantedScope: nil, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), RequestedAudience: nil, GrantedAudience: nil, } @@ -108,6 +103,7 @@ func TestPKCEStorage(t *testing.T) { err = storage.DeletePKCERequestSession(ctx, "fancy-signature") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -134,7 +130,7 @@ func TestWrongVersion(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"not-the-right-version"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", @@ -144,7 +140,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetPKCERequestSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 1") + require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 2") } func TestNilSessionRequest(t *testing.T) { @@ -162,7 +158,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", @@ -190,10 +186,10 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) { Client: &clientregistry.Client{}, } err := storage.CreatePKCERequestSession(ctx, "signature-doesnt-matter", request) - require.EqualError(t, err, "requester's session must be of type openid.DefaultSession") + require.EqualError(t, err, "requester's session must be of type PinnipedSession") request = &fosite.Request{ - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: nil, } err = storage.CreatePKCERequestSession(ctx, "signature-doesnt-matter", request) diff --git a/internal/fositestorage/refreshtoken/refreshtoken.go b/internal/fositestorage/refreshtoken/refreshtoken.go index f53d9bf7..c2d4336c 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken.go +++ b/internal/fositestorage/refreshtoken/refreshtoken.go @@ -10,7 +10,6 @@ import ( "github.com/ory/fosite" "github.com/ory/fosite/handler/oauth2" - "github.com/ory/fosite/handler/openid" "k8s.io/apimachinery/pkg/api/errors" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -18,6 +17,7 @@ import ( "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" ) const ( @@ -26,7 +26,9 @@ const ( ErrInvalidRefreshTokenRequestVersion = constable.Error("refresh token request data has wrong version") ErrInvalidRefreshTokenRequestData = constable.Error("refresh token request data must be present") - refreshTokenStorageVersion = "1" + // Version 1 was the initial release of storage. + // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. + refreshTokenStorageVersion = "2" ) type RevocationStorage interface { @@ -110,7 +112,7 @@ func newValidEmptyRefreshTokenSession() *session { return &session{ Request: &fosite.Request{ Client: &clientregistry.Client{}, - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, }, } } diff --git a/internal/fositestorage/refreshtoken/refreshtoken_test.go b/internal/fositestorage/refreshtoken/refreshtoken_test.go index 10ee75bf..e8a155c9 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken_test.go +++ b/internal/fositestorage/refreshtoken/refreshtoken_test.go @@ -10,7 +10,6 @@ import ( "time" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/pkg/errors" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -22,6 +21,8 @@ import ( coretesting "k8s.io/client-go/testing" "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/psession" + "go.pinniped.dev/internal/testutil" ) const namespace = "test-ns" @@ -50,7 +51,7 @@ func TestRefreshTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -84,16 +85,10 @@ func TestRefreshTokenStorage(t *testing.T) { TokenEndpointAuthSigningAlgorithm: "", }, }, - RequestedScope: nil, - GrantedScope: nil, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Claims: nil, - Headers: nil, - ExpiresAt: nil, - Username: "snorlax", - Subject: "panda", - }, + RequestedScope: nil, + GrantedScope: nil, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), RequestedAudience: nil, GrantedAudience: nil, } @@ -107,6 +102,7 @@ func TestRefreshTokenStorage(t *testing.T) { err = storage.DeleteRefreshTokenSession(ctx, "fancy-signature") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -125,7 +121,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"custom":{"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}},"requestedAudience":null,"grantedAudience":null},"version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -151,11 +147,8 @@ func TestRefreshTokenStorageRevocation(t *testing.T) { TokenEndpointAuthMethod: "something", }, }, - Form: url.Values{"key": []string{"val"}}, - Session: &openid.DefaultSession{ - Username: "snorlax", - Subject: "panda", - }, + Form: url.Values{"key": []string{"val"}}, + Session: testutil.NewFakePinnipedSession(), } err := storage.CreateRefreshTokenSession(ctx, "fancy-signature", request) require.NoError(t, err) @@ -164,6 +157,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) { err = storage.RevokeRefreshToken(ctx, "abcd-1") require.NoError(t, err) + testutil.LogActualJSONFromCreateAction(t, client, 0) // makes it easier to update expected values when needed require.Equal(t, wantActions, client.Actions()) } @@ -190,7 +184,7 @@ func TestWrongVersion(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"not-the-right-version"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"not-the-right-version"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -200,7 +194,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 1") + require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 2") } func TestNilSessionRequest(t *testing.T) { @@ -218,7 +212,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"1"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"2"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -246,10 +240,10 @@ func TestCreateWithWrongRequesterDataTypes(t *testing.T) { Client: &clientregistry.Client{}, } err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request) - require.EqualError(t, err, "requester's session must be of type openid.DefaultSession") + require.EqualError(t, err, "requester's session must be of type PinnipedSession") request = &fosite.Request{ - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: nil, } err = storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request) @@ -261,7 +255,7 @@ func TestCreateWithoutRequesterID(t *testing.T) { request := &fosite.Request{ ID: "", // empty ID - Session: &openid.DefaultSession{}, + Session: &psession.PinnipedSession{}, Client: &clientregistry.Client{}, } err := storage.CreateRefreshTokenSession(ctx, "signature-doesnt-matter", request) diff --git a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go index 152f33e2..fd8b7dd9 100644 --- a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go +++ b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go @@ -18,6 +18,7 @@ import ( oidctypes "go.pinniped.dev/pkg/oidcclient/oidctypes" pkce "go.pinniped.dev/pkg/oidcclient/pkce" oauth2 "golang.org/x/oauth2" + types "k8s.io/apimachinery/pkg/types" ) // MockUpstreamOIDCIdentityProviderI is a mock of UpstreamOIDCIdentityProviderI interface. @@ -72,6 +73,20 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) ExchangeAuthcodeAndVali return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExchangeAuthcodeAndValidateTokens", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).ExchangeAuthcodeAndValidateTokens), arg0, arg1, arg2, arg3, arg4) } +// GetAdditionalAuthcodeParams mocks base method. +func (m *MockUpstreamOIDCIdentityProviderI) GetAdditionalAuthcodeParams() map[string]string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAdditionalAuthcodeParams") + ret0, _ := ret[0].(map[string]string) + return ret0 +} + +// GetAdditionalAuthcodeParams indicates an expected call of GetAdditionalAuthcodeParams. +func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetAdditionalAuthcodeParams() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAdditionalAuthcodeParams", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetAdditionalAuthcodeParams)) +} + // GetAuthorizationURL mocks base method. func (m *MockUpstreamOIDCIdentityProviderI) GetAuthorizationURL() *url.URL { m.ctrl.T.Helper() @@ -128,6 +143,20 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetName() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetName)) } +// GetResourceUID mocks base method. +func (m *MockUpstreamOIDCIdentityProviderI) GetResourceUID() types.UID { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetResourceUID") + ret0, _ := ret[0].(types.UID) + return ret0 +} + +// GetResourceUID indicates an expected call of GetResourceUID. +func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) GetResourceUID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceUID", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).GetResourceUID)) +} + // GetScopes mocks base method. func (m *MockUpstreamOIDCIdentityProviderI) GetScopes() []string { m.ctrl.T.Helper() @@ -171,6 +200,21 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) PasswordCredentialsGran return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCredentialsGrantAndValidateTokens", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).PasswordCredentialsGrantAndValidateTokens), arg0, arg1, arg2) } +// PerformRefresh mocks base method. +func (m *MockUpstreamOIDCIdentityProviderI) PerformRefresh(arg0 context.Context, arg1 string) (*oauth2.Token, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PerformRefresh", arg0, arg1) + ret0, _ := ret[0].(*oauth2.Token) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PerformRefresh indicates an expected call of PerformRefresh. +func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) PerformRefresh(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PerformRefresh", reflect.TypeOf((*MockUpstreamOIDCIdentityProviderI)(nil).PerformRefresh), arg0, arg1) +} + // ValidateToken mocks base method. func (m *MockUpstreamOIDCIdentityProviderI) ValidateToken(arg0 context.Context, arg1 *oauth2.Token, arg2 nonce.Nonce) (*oidctypes.Token, error) { m.ctrl.T.Helper() diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 5dd9e211..4c457faf 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -25,10 +25,16 @@ import ( "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) +const ( + promptParamName = "prompt" + promptParamNone = "none" +) + func NewHandler( downstreamIssuer string, idpLister oidc.UpstreamIdentityProvidersLister, @@ -48,13 +54,13 @@ func NewHandler( return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } - oidcUpstream, ldapUpstream, err := chooseUpstreamIDP(idpLister) + oidcUpstream, ldapUpstream, idpType, err := chooseUpstreamIDP(idpLister) if err != nil { plog.WarningErr("authorize upstream config", err) return err } - if oidcUpstream != nil { + if idpType == psession.ProviderTypeOIDC { if len(r.Header.Values(supervisoroidc.AuthorizeUsernameHeaderName)) > 0 { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) @@ -71,6 +77,7 @@ func NewHandler( return handleAuthRequestForLDAPUpstream(r, w, oauthHelperWithStorage, ldapUpstream, + idpType, ) })) } @@ -80,6 +87,7 @@ func handleAuthRequestForLDAPUpstream( w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, ldapUpstream provider.UpstreamLDAPIdentityProviderI, + idpType psession.ProviderType, ) error { authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper) if !created { @@ -105,7 +113,14 @@ func handleAuthRequestForLDAPUpstream( username = authenticateResponse.User.GetName() groups := authenticateResponse.User.GetGroups() - return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) + customSessionData := &psession.CustomSessionData{ + ProviderUID: ldapUpstream.GetResourceUID(), + ProviderName: ldapUpstream.GetName(), + ProviderType: idpType, + } + + return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, + oauthHelper, authorizeRequester, subject, username, groups, customSessionData) } func handleAuthRequestForOIDCUpstreamPasswordGrant( @@ -144,6 +159,16 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( fosite.ErrAccessDenied.WithDebug(err.Error())) // WithDebug hides the error from the client } + if token.RefreshToken == nil || token.RefreshToken.Token == "" { + plog.Warning("refresh token not returned by upstream provider during password grant, "+ + "please check configuration of OIDCIdentityProvider and the client in the upstream provider's API/UI", + "upstreamName", oidcUpstream.GetName(), + "scopes", oidcUpstream.GetScopes()) + return writeAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHint( + "Refresh token not returned by upstream provider during password grant.")) + } + subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) if err != nil { // Return a user-friendly error for this case which is entirely within our control. @@ -152,7 +177,15 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( ) } - return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups) + customSessionData := &psession.CustomSessionData{ + ProviderUID: oidcUpstream.GetResourceUID(), + ProviderName: oidcUpstream.GetName(), + ProviderType: psession.ProviderTypeOIDC, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: token.RefreshToken.Token, + }, + } + return makeDownstreamSessionAndReturnAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, subject, username, groups, customSessionData) } func handleAuthRequestForOIDCUpstreamAuthcodeGrant( @@ -173,12 +206,14 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( } now := time.Now() - _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. - Subject: "none", - AuthTime: now, - RequestedAt: now, + _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + // Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations. + Subject: "none", + AuthTime: now, + RequestedAt: now, + }, }, }) if err != nil { @@ -217,6 +252,21 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( return err } + authCodeOptions := []oauth2.AuthCodeOption{ + nonceValue.Param(), + pkceValue.Challenge(), + pkceValue.Method(), + } + + promptParam := r.Form.Get(promptParamName) + if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { + return writeAuthorizeError(w, oauthHelper, authorizeRequester, fosite.ErrLoginRequired) + } + + for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() { + authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val)) + } + if csrfFromCookie == "" { // We did not receive an incoming CSRF cookie, so write a new one. err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec) @@ -226,18 +276,6 @@ func handleAuthRequestForOIDCUpstreamAuthcodeGrant( } } - authCodeOptions := []oauth2.AuthCodeOption{ - oauth2.AccessTypeOffline, - nonceValue.Param(), - pkceValue.Challenge(), - pkceValue.Method(), - } - - promptParam := r.Form.Get("prompt") - if promptParam != "" && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) { - authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("prompt", promptParam)) - } - http.Redirect(w, r, upstreamOAuthConfig.AuthCodeURL( encodedStateParamValue, @@ -275,8 +313,9 @@ func makeDownstreamSessionAndReturnAuthcodeRedirect( subject string, username string, groups []string, + customSessionData *psession.CustomSessionData, ) error { - openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, customSessionData) authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { @@ -335,13 +374,13 @@ func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken { } // Select either an OIDC, an LDAP or an AD IDP, or return an error. -func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, error) { +func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, psession.ProviderType, error) { oidcUpstreams := idpLister.GetOIDCIdentityProviders() ldapUpstreams := idpLister.GetLDAPIdentityProviders() adUpstreams := idpLister.GetActiveDirectoryIdentityProviders() switch { case len(oidcUpstreams)+len(ldapUpstreams)+len(adUpstreams) == 0: - return nil, nil, httperr.New( + return nil, nil, "", httperr.New( http.StatusUnprocessableEntity, "No upstream providers are configured", ) @@ -357,16 +396,16 @@ func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) } plog.Warning("Too many upstream providers are configured (found: %s)", upstreamIDPNames) - return nil, nil, httperr.New( + return nil, nil, "", httperr.New( http.StatusUnprocessableEntity, "Too many upstream providers are configured (support for multiple upstreams is not yet implemented)", ) case len(oidcUpstreams) == 1: - return oidcUpstreams[0], nil, nil + return oidcUpstreams[0], nil, psession.ProviderTypeOIDC, nil case len(adUpstreams) == 1: - return nil, adUpstreams[0], nil + return nil, adUpstreams[0], psession.ProviderTypeActiveDirectory, nil default: - return nil, ldapUpstreams[0], nil + return nil, ldapUpstreams[0], psession.ProviderTypeLDAP, nil } } diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index c0198ee1..7f61c103 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -30,6 +30,7 @@ import ( "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -38,16 +39,23 @@ import ( func TestAuthorizationEndpoint(t *testing.T) { const ( - oidcUpstreamName = "some-oidc-idp" - oidcPasswordGrantUpstreamName = "some-password-granting-oidc-idp" + oidcUpstreamName = "some-oidc-idp" + oidcUpstreamResourceUID = "oidc-resource-uid" + oidcPasswordGrantUpstreamName = "some-password-granting-oidc-idp" + oidcPasswordGrantUpstreamResourceUID = "some-password-granting-resource-uid" + ldapUpstreamName = "some-ldap-idp" + ldapUpstreamResourceUID = "ldap-resource-uid" + activeDirectoryUpstreamName = "some-active-directory-idp" + activeDirectoryUpstreamResourceUID = "active-directory-resource-uid" - oidcUpstreamIssuer = "https://my-upstream-issuer.com" - oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL - oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" - oidcUpstreamUsername = "test-oidc-pinniped-username" - oidcUpstreamPassword = "test-oidc-pinniped-password" //nolint: gosec - oidcUpstreamUsernameClaim = "the-user-claim" - oidcUpstreamGroupsClaim = "the-groups-claim" + oidcUpstreamIssuer = "https://my-upstream-issuer.com" + oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL + oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" + oidcUpstreamUsername = "test-oidc-pinniped-username" + oidcUpstreamPassword = "test-oidc-pinniped-password" //nolint: gosec + oidcUpstreamUsernameClaim = "the-user-claim" + oidcUpstreamGroupsClaim = "the-groups-claim" + oidcPasswordGrantUpstreamRefreshToken = "some-opaque-token" //nolint: gosec downstreamIssuer = "https://my-downstream-issuer.com/some-path" downstreamRedirectURI = "http://127.0.0.1/callback" @@ -146,6 +154,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": happyState, } + fositeAccessDeniedWithMissingRefreshTokenErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Refresh token not returned by upstream provider during password grant.", + "state": happyState, + } + fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery = map[string]string{ "error": "access_denied", "error_description": "The resource owner or authorization server denied the request. Resource owner password credentials grant is not allowed for this upstream provider according to its configuration.", @@ -181,6 +195,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "error_description": "The resource owner or authorization server denied the request. Reason: required claim in upstream ID token has invalid format.", "state": happyState, } + + fositeLoginRequiredErrorQuery = map[string]string{ + "error": "login_required", + "error_description": "The Authorization Server requires End-User authentication.", + "state": happyState, + } ) hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } @@ -202,20 +222,22 @@ func TestAuthorizationEndpoint(t *testing.T) { upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") require.NoError(t, err) - upstreamOIDCIdentityProvider := func() *oidctestutil.TestUpstreamOIDCIdentityProvider { + upstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(oidcUpstreamName). + WithResourceUID(oidcUpstreamResourceUID). WithClientID("some-client-id"). WithAuthorizationURL(*upstreamAuthURL). WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow WithAllowPasswordGrant(false). - WithPasswordGrantError(errors.New("should not have used password grant on this instance")). - Build() + WithAdditionalAuthcodeParams(map[string]string{}). + WithPasswordGrantError(errors.New("should not have used password grant on this instance")) } passwordGrantUpstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(oidcPasswordGrantUpstreamName). + WithResourceUID(oidcPasswordGrantUpstreamResourceUID). WithClientID("some-client-id"). WithAuthorizationURL(*upstreamAuthURL). WithScopes([]string{"scope1", "scope2"}). // the scopes to request when starting the upstream authorization flow @@ -228,6 +250,8 @@ func TestAuthorizationEndpoint(t *testing.T) { WithIDTokenClaim(oidcUpstreamGroupsClaim, oidcUpstreamGroupMembership). WithIDTokenClaim("other-claim", "should be ignored"). WithAllowPasswordGrant(true). + WithRefreshToken(oidcPasswordGrantUpstreamRefreshToken). + WithAdditionalAuthcodeParams(map[string]string{"should-be-ignored": "doesn't apply to password grant"}). WithUpstreamAuthcodeExchangeError(errors.New("should not have tried to exchange upstream authcode on this instance")) } @@ -248,28 +272,39 @@ func TestAuthorizationEndpoint(t *testing.T) { parsedUpstreamLDAPURL, err := url.Parse(upstreamLDAPURL) require.NoError(t, err) + ldapAuthenticateFunc := func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + if username == "" || password == "" { + return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator") + } + if username == happyLDAPUsername && password == happyLDAPPassword { + return &authenticator.Response{ + User: &user.DefaultInfo{ + Name: happyLDAPUsernameFromAuthenticator, + UID: happyLDAPUID, + Groups: happyLDAPGroups, + }, + }, true, nil + } + return nil, false, nil + } + upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: "some-ldap-idp", - URL: parsedUpstreamLDAPURL, - AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { - if username == "" || password == "" { - return nil, false, fmt.Errorf("should not have passed empty username or password to the authenticator") - } - if username == happyLDAPUsername && password == happyLDAPPassword { - return &authenticator.Response{ - User: &user.DefaultInfo{ - Name: happyLDAPUsernameFromAuthenticator, - UID: happyLDAPUID, - Groups: happyLDAPGroups, - }, - }, true, nil - } - return nil, false, nil - }, + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: parsedUpstreamLDAPURL, + AuthenticateFunc: ldapAuthenticateFunc, + } + + upstreamActiveDirectoryIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: activeDirectoryUpstreamName, + ResourceUID: activeDirectoryUpstreamResourceUID, + URL: parsedUpstreamLDAPURL, + AuthenticateFunc: ldapAuthenticateFunc, } erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: "some-ldap-idp", + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { return nil, false, fmt.Errorf("some ldap upstream auth error") }, @@ -381,10 +416,9 @@ func TestAuthorizationEndpoint(t *testing.T) { return encoded } - expectedRedirectLocationForUpstreamOIDC := func(expectedUpstreamState string, expectedPrompt string) string { + expectedRedirectLocationForUpstreamOIDC := func(expectedUpstreamState string, expectedAdditionalParams map[string]string) string { query := map[string]string{ "response_type": "code", - "access_type": "offline", "scope": "scope1 scope2", "client_id": "some-client-id", "state": expectedUpstreamState, @@ -393,12 +427,35 @@ func TestAuthorizationEndpoint(t *testing.T) { "code_challenge_method": downstreamPKCEChallengeMethod, "redirect_uri": downstreamIssuer + "/callback", } - if expectedPrompt != "" { - query["prompt"] = expectedPrompt + for key, val := range expectedAdditionalParams { + query[key] = val } return urlWithQuery(upstreamAuthURL.String(), query) } + expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{ + ProviderUID: activeDirectoryUpstreamResourceUID, + ProviderName: activeDirectoryUpstreamName, + ProviderType: psession.ProviderTypeActiveDirectory, + OIDC: nil, + } + + expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ + ProviderUID: ldapUpstreamResourceUID, + ProviderName: ldapUpstreamName, + ProviderType: psession.ProviderTypeLDAP, + OIDC: nil, + } + + expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{ + ProviderUID: oidcPasswordGrantUpstreamResourceUID, + ProviderName: oidcPasswordGrantUpstreamName, + ProviderType: psession.ProviderTypeOIDC, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: oidcPasswordGrantUpstreamRefreshToken, + }, + } + // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid&state=` + happyState @@ -446,11 +503,12 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce string wantUnnecessaryStoredRecords int wantPasswordGrantCall *expectedPasswordGrant + wantDownstreamCustomSessionData *psession.CustomSessionData } tests := []testCase{ { name: "OIDC upstream browser flow happy path using GET without a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -461,7 +519,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -485,6 +543,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "LDAP upstream happy path using GET", @@ -505,10 +564,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { name: "ActiveDirectory upstream happy path using GET", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -525,10 +585,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, }, { name: "OIDC upstream browser flow happy path using GET with a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -539,13 +600,13 @@ func TestAuthorizationEndpoint(t *testing.T) { csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusFound, wantContentType: htmlContentType, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, incomingCookieCSRFValue, ""), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { name: "OIDC upstream browser flow happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -559,7 +620,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, }, { @@ -584,6 +645,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "LDAP upstream happy path using POST", @@ -606,10 +668,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { name: "Active Directory upstream happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodPost, path: "/some/path", contentType: "application/x-www-form-urlencoded", @@ -628,10 +691,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, }, { - name: "OIDC upstream browser flow happy path with prompt param login passed through to redirect uri", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + name: "OIDC upstream browser flow happy path with prompt param other than none that gets ignored", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -645,12 +709,48 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), "login"), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "OIDC upstream browser flow happy path with extra params that get passed through", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().WithAdditionalAuthcodeParams(map[string]string{"prompt": "consent", "abc": "123", "def": "456"}).Build()), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantBodyStringWithLocationInHref: true, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"prompt": "login"}, "", ""), map[string]string{"prompt": "consent", "abc": "123", "def": "456"}), + wantUpstreamStateParamInLocationHeader: true, + }, + { + name: "OIDC upstream browser flow with prompt param none throws an error because we want to independently decide the upstream prompt param", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}), + contentType: "application/x-www-form-urlencoded", + body: encodeQuery(happyGetRequestQueryMap), + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), + wantBodyString: "", + }, { name: "OIDC upstream browser flow with error while decoding CSRF cookie just generates a new cookie and succeeds as usual", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -663,13 +763,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, // Generated a new CSRF cookie and set it in the response. wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), ""), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, { name: "OIDC upstream browser flow happy path when downstream redirect uri matches what is configured for client except for the port number", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -684,7 +784,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client - }, "", ""), ""), + }, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -710,6 +810,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "LDAP upstream happy path when downstream redirect uri matches what is configured for client except for the port number", @@ -732,10 +833,11 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { name: "OIDC upstream browser flow happy path when downstream requested scopes include offline_access", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -748,7 +850,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "scope": "openid offline_access", - }, "", ""), ""), + }, "", ""), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -811,7 +913,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream password for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -835,7 +937,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream username for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr("wrong-username"), @@ -859,7 +961,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream username on request for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: nil, // do not send header @@ -883,7 +985,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream password on request for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -893,6 +995,32 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingUsernamePasswordHintErrorQuery), wantBodyString: "", }, + { + name: "return an error when upstream IDP did not return a refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingRefreshTokenErrorQuery), + wantBodyString: "", + }, + { + name: "return an error when upstream IDP did not return a refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().Build()), + method: http.MethodGet, + path: happyGetRequestPath, + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: "application/json; charset=utf-8", + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithMissingRefreshTokenErrorQuery), + wantBodyString: "", + }, { name: "missing upstream password on request for OIDC password grant authentication", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -907,7 +1035,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "using the custom username header on request for OIDC password grant authentication when OIDCIdentityProvider does not allow password grants", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, path: happyGetRequestPath, customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), @@ -919,7 +1047,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -961,7 +1089,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream redirect uri does not match what is configured for client when using active directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", @@ -974,7 +1102,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream client does not exist when using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1008,7 +1136,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream client does not exist when using active directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, @@ -1017,7 +1145,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1054,7 +1182,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using active directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusFound, @@ -1064,7 +1192,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1103,7 +1231,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream scopes do not match what is configured for client using Active Directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid tuna"}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), @@ -1115,7 +1243,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing response type in request using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1152,7 +1280,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing response type in request using Active Directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), method: http.MethodGet, path: modifiedHappyGetRequestPath(map[string]string{"response_type": ""}), wantStatus: http.StatusFound, @@ -1162,7 +1290,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing client id in request using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1196,7 +1324,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing PKCE code_challenge in request using OIDC upstream browser flow", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1238,7 +1366,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "invalid value for PKCE code_challenge_method in request using OIDC upstream browser flow", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1280,7 +1408,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "when PKCE code_challenge_method in request is `plain` using OIDC upstream browser flow", // https://tools.ietf.org/html/rfc7636#section-4.3 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1322,7 +1450,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing PKCE code_challenge_method in request using OIDC upstream browser flow", // See https://tools.ietf.org/html/rfc7636#section-4.4.1 - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1366,7 +1494,7 @@ func TestAuthorizationEndpoint(t *testing.T) { // This is just one of the many OIDC validations run by fosite. This test is to ensure that we are running // through that part of the fosite library when using an OIDC upstream browser flow. name: "prompt param is not allowed to have none and another legal value at the same time using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1412,7 +1540,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1426,7 +1554,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantCSRFValueInCookieHeader: happyCSRF, wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam( map[string]string{"prompt": "none login", "scope": "email"}, "", "", - ), ""), + ), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -1451,6 +1579,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "happy path: downstream OIDC validations are skipped when the openid scope was not requested using LDAP upstream", @@ -1472,6 +1601,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { name: "OIDC upstream password grant: upstream IDP provides no username or group claim configuration, so we use default username claim and skip groups", @@ -1495,6 +1625,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is missing", @@ -1520,6 +1651,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with true value", @@ -1546,6 +1678,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP configures username claim as anything other than special claim `email` and `email_verified` upstream claim is present with false value", @@ -1573,6 +1706,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with illegal value", @@ -1632,6 +1766,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token has a non-array value", @@ -1656,6 +1791,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream IDP's configured groups claim in the ID token is a slice of interfaces", @@ -1680,6 +1816,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream ID token does not contain requested username claim", @@ -1718,6 +1855,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, { name: "OIDC upstream password grant: upstream ID token contains username claim with weird format", @@ -1886,7 +2024,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream state does not have enough entropy using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1925,7 +2063,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while encoding upstream state param using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1939,7 +2077,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while encoding CSRF cookie value for new cookie using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1953,7 +2091,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating CSRF token using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: sadCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -1967,7 +2105,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating nonce using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: sadNonceGenerator, @@ -1981,7 +2119,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error while generating PKCE using OIDC upstream browser flow", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: sadPKCEGenerator, generateNonce: happyNonceGenerator, @@ -2004,7 +2142,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: multiple OIDC", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider(), upstreamOIDCIdentityProvider()), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build(), upstreamOIDCIdentityProviderBuilder().Build()), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -2031,7 +2169,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: both OIDC and LDAP", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()).WithLDAP(&upstreamLDAPIdentityProvider), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()).WithLDAP(&upstreamLDAPIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -2040,7 +2178,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "too many upstream providers are configured: OIDC, LDAP and AD", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()).WithLDAP(&upstreamLDAPIdentityProvider).WithActiveDirectory(&upstreamLDAPIdentityProvider), // more than one not allowed + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()).WithLDAP(&upstreamLDAPIdentityProvider).WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), // more than one not allowed method: http.MethodGet, path: happyGetRequestPath, wantStatus: http.StatusUnprocessableEntity, @@ -2049,7 +2187,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PUT is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodPut, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -2058,7 +2196,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "PATCH is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodPatch, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -2067,7 +2205,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "DELETE is a bad method", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProvider()), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodDelete, path: "/some/path", wantStatus: http.StatusMethodNotAllowed, @@ -2143,6 +2281,7 @@ func TestAuthorizationEndpoint(t *testing.T) { test.wantDownstreamNonce, downstreamClientID, test.wantDownstreamRedirectURI, + test.wantDownstreamCustomSessionData, ) default: require.Empty(t, rsp.Header().Values("Location")) @@ -2216,6 +2355,7 @@ func TestAuthorizationEndpoint(t *testing.T) { WithClientID("some-other-new-client-id"). WithAuthorizationURL(*upstreamAuthURL). WithScopes([]string{"some-other-new-scope1", "some-other-new-scope2"}). + WithAdditionalAuthcodeParams(map[string]string{"prompt": "consent", "abc": "123"}). Build() idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(newProviderSettings)}) @@ -2223,7 +2363,8 @@ func TestAuthorizationEndpoint(t *testing.T) { test.wantLocationHeader = urlWithQuery(upstreamAuthURL.String(), map[string]string{ "response_type": "code", - "access_type": "offline", + "prompt": "consent", + "abc": "123", "scope": "some-other-new-scope1 some-other-new-scope2", // updated expectation "client_id": "some-other-new-client-id", // updated expectation "state": expectedUpstreamStateParam( diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index b168e4b9..22f37a55 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -19,6 +19,7 @@ import ( "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/psession" ) func NewHandler( @@ -68,12 +69,28 @@ func NewHandler( return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") } + if token.RefreshToken == nil || token.RefreshToken.Token == "" { + plog.Warning("refresh token not returned by upstream provider during authcode exchange, "+ + "please check configuration of OIDCIdentityProvider and the client in the upstream provider's API/UI", + "upstreamName", upstreamIDPConfig.GetName(), + "scopes", upstreamIDPConfig.GetScopes(), + "additionalParams", upstreamIDPConfig.GetAdditionalAuthcodeParams()) + return httperr.New(http.StatusUnprocessableEntity, "refresh token not returned by upstream provider during authcode exchange") + } + subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) if err != nil { return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } - openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups) + openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, &psession.CustomSessionData{ + ProviderUID: upstreamIDPConfig.GetResourceUID(), + ProviderName: upstreamIDPConfig.GetName(), + ProviderType: psession.ProviderTypeOIDC, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: token.RefreshToken.Token, + }, + }) authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index 9cc5779e..6bfcf340 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -18,6 +18,7 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -25,9 +26,11 @@ import ( ) const ( - happyUpstreamIDPName = "upstream-idp-name" + happyUpstreamIDPName = "upstream-idp-name" + happyUpstreamIDPResourceUID = "upstream-uid" oidcUpstreamIssuer = "https://my-upstream-issuer.com" + oidcUpstreamRefreshToken = "test-refresh-token" oidcUpstreamSubject = "abc123-some guid" // has a space character which should get escaped in URL oidcUpstreamSubjectQueryEscaped = "abc123-some+guid" oidcUpstreamUsername = "test-pinniped-username" @@ -69,7 +72,13 @@ var ( "code_challenge_method": []string{downstreamPKCEChallengeMethod}, "redirect_uri": []string{downstreamRedirectURI}, } - happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode() + happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode() + happyDownstreamCustomSessionData = &psession.CustomSessionData{ + ProviderUID: happyUpstreamIDPResourceUID, + ProviderName: happyUpstreamIDPName, + ProviderType: psession.ProviderTypeOIDC, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamRefreshToken}, + } ) func TestCallbackEndpoint(t *testing.T) { @@ -130,6 +139,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce string wantDownstreamPKCEChallenge string wantDownstreamPKCEChallengeMethod string + wantDownstreamCustomSessionData *psession.CustomSessionData wantAuthcodeExchangeCall *expectedAuthcodeExchange }{ @@ -157,6 +167,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -179,6 +190,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -203,6 +215,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -227,6 +240,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -253,6 +267,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -280,6 +295,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -302,6 +318,34 @@ func TestCallbackEndpoint(t *testing.T) { args: happyExchangeAndValidateTokensArgs, }, }, + { + name: "return an error when upstream IDP did not return a refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithoutRefreshToken().Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: refresh token not returned by upstream provider during authcode exchange\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "return an error when upstream IDP returned an empty refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().WithEmptyRefreshToken().Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: refresh token not returned by upstream provider during authcode exchange\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, { name: "upstream IDP configures username claim as special claim `email` and `email_verified` upstream claim is present with false value", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( @@ -339,6 +383,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -363,6 +408,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -387,6 +433,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -537,6 +584,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -563,6 +611,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -660,6 +709,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamNonce: downstreamNonce, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -907,6 +957,7 @@ func TestCallbackEndpoint(t *testing.T) { test.wantDownstreamNonce, downstreamClientID, downstreamRedirectURI, + test.wantDownstreamCustomSessionData, ) // Otherwise, expect an empty response body. @@ -933,6 +984,7 @@ func TestCallbackEndpoint(t *testing.T) { test.wantDownstreamNonce, downstreamClientID, downstreamRedirectURI, + test.wantDownstreamCustomSessionData, ) } }) @@ -1036,6 +1088,7 @@ func (b *upstreamStateParamBuilder) WithStateVersion(version string) *upstreamSt func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(happyUpstreamIDPName). + WithResourceUID(happyUpstreamIDPResourceUID). WithClientID("some-client-id"). WithScopes([]string{"scope1", "scope2"}). WithUsernameClaim(oidcUpstreamUsernameClaim). @@ -1046,6 +1099,7 @@ func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { WithIDTokenClaim(oidcUpstreamGroupsClaim, oidcUpstreamGroupMembership). WithIDTokenClaim("other-claim", "should be ignored"). WithAllowPasswordGrant(false). + WithRefreshToken(oidcUpstreamRefreshToken). WithPasswordGrantError(errors.New("the callback endpoint should not use password grants")) } diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/oidc/downstreamsession/downstream_session.go index 64838d5e..0fee5a78 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/oidc/downstreamsession/downstream_session.go @@ -18,6 +18,7 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/psession" ) const ( @@ -35,19 +36,22 @@ const ( ) // MakeDownstreamSession creates a downstream OIDC session. -func MakeDownstreamSession(subject string, username string, groups []string) *openid.DefaultSession { +func MakeDownstreamSession(subject string, username string, groups []string, custom *psession.CustomSessionData) *psession.PinnipedSession { now := time.Now().UTC() - openIDSession := &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Subject: subject, - RequestedAt: now, - AuthTime: now, + openIDSession := &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Subject: subject, + RequestedAt: now, + AuthTime: now, + }, }, + Custom: custom, } if groups == nil { groups = []string{} } - openIDSession.Claims.Extra = map[string]interface{}{ + openIDSession.IDTokenClaims().Extra = map[string]interface{}{ oidc.DownstreamUsernameClaim: username, oidc.DownstreamGroupsClaim: groups, } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index b70e0309..6c3c1918 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -210,8 +210,6 @@ func FositeOauth2Helper( // The default is to support all prompt values from the spec. // See https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest - // We'll make a best effort to support these by passing the value of this prompt param to the upstream IDP - // and rely on its implementation of this param. AllowedPromptValues: nil, // Use the fosite default to make it more likely that off the shelf OIDC clients can work with the supervisor. @@ -232,7 +230,7 @@ func FositeOauth2Helper( compose.OpenIDConnectExplicitFactory, compose.OpenIDConnectRefreshFactory, compose.OAuth2PKCEFactory, - TokenExchangeFactory, + TokenExchangeFactory, // handle the "urn:ietf:params:oauth:grant-type:token-exchange" grant type ) provider.(*fosite.Fosite).FormPostHTMLTemplate = formposthtml.Template() return provider diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/oidc/provider/dynamic_upstream_idp_provider.go index ef57f6e5..88710f00 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/oidc/provider/dynamic_upstream_idp_provider.go @@ -9,6 +9,7 @@ import ( "sync" "golang.org/x/oauth2" + "k8s.io/apimachinery/pkg/types" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -24,6 +25,9 @@ type UpstreamOIDCIdentityProviderI interface { // GetClientID returns the OAuth client ID registered with the upstream provider to be used in the authorization code flow. GetClientID() string + // GetResourceUID returns the Kubernetes resource ID + GetResourceUID() types.UID + // GetAuthorizationURL returns the Authorization Endpoint fetched from discovery. GetAuthorizationURL() *url.URL @@ -42,6 +46,9 @@ type UpstreamOIDCIdentityProviderI interface { // flow with this upstream provider. When false, it should not be allowed. AllowsPasswordGrant() bool + // GetAdditionalAuthcodeParams returns additional params to be sent on authcode requests. + GetAdditionalAuthcodeParams() map[string]string + // PasswordCredentialsGrantAndValidateTokens performs upstream OIDC resource owner password credentials grant and // token validation. Returns the validated raw tokens as well as the parsed claims of the ID token. PasswordCredentialsGrantAndValidateTokens(ctx context.Context, username, password string) (*oidctypes.Token, error) @@ -56,6 +63,14 @@ type UpstreamOIDCIdentityProviderI interface { redirectURI string, ) (*oidctypes.Token, error) + // PerformRefresh will call the provider's token endpoint to perform a refresh grant. The provider may or may not + // return a new ID or refresh token in the response. If it returns an ID token, then use ValidateToken to + // validate the ID token. + PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) + + // ValidateToken will validate the ID token. It will also merge the claims from the userinfo endpoint response + // into the ID token's claims, if the provider offers the userinfo endpoint. It returns the validated/updated + // tokens, or an error. ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) } @@ -68,6 +83,9 @@ type UpstreamLDAPIdentityProviderI interface { // identifier by being combined with the user's UID, since user UIDs are only unique within one provider. GetURL() *url.URL + // GetResourceUID returns the Kubernetes resource ID + GetResourceUID() types.UID + // UserAuthenticator adds an interface method for performing user authentication against the upstream LDAP provider. authenticators.UserAuthenticator } diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index ea1d2d62..708d4855 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -130,6 +130,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs ) m.providerHandlers[(issuerHostWithPath + oidc.TokenEndpointPath)] = token.NewHandler( + m.upstreamIDPs, oauthHelperWithKubeStorage, ) diff --git a/internal/oidc/provider/manager/manager_test.go b/internal/oidc/provider/manager/manager_test.go index 2731b488..e78b9126 100644 --- a/internal/oidc/provider/manager/manager_test.go +++ b/internal/oidc/provider/manager/manager_test.go @@ -264,6 +264,7 @@ func TestManager(t *testing.T) { "groups": "test-group1", }, }, + RefreshToken: &oidctypes.RefreshToken{Token: "some-opaque-token"}, }, nil }, }).Build() diff --git a/internal/oidc/token/token_handler.go b/internal/oidc/token/token_handler.go index c481168b..30956524 100644 --- a/internal/oidc/token/token_handler.go +++ b/internal/oidc/token/token_handler.go @@ -1,32 +1,65 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package token provides a handler for the OIDC token endpoint. package token import ( + "context" "net/http" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" + "github.com/ory/x/errorsx" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/psession" +) + +var ( + errMissingUpstreamSessionInternalError = &fosite.RFC6749Error{ + ErrorField: "error", + DescriptionField: "There was an internal server error.", + HintField: "Required upstream data not found in session.", + CodeField: http.StatusInternalServerError, + } + + errUpstreamRefreshError = &fosite.RFC6749Error{ + ErrorField: "error", + DescriptionField: "Error during upstream refresh.", + CodeField: http.StatusUnauthorized, + } ) func NewHandler( + idpLister oidc.UpstreamIdentityProvidersLister, oauthHelper fosite.OAuth2Provider, ) http.Handler { return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { - var session openid.DefaultSession - accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, &session) + session := psession.NewPinnipedSession() + accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session) if err != nil { plog.Info("token request error", oidc.FositeErrorForLog(err)...) oauthHelper.WriteAccessError(w, accessRequest, err) return nil } + // Check if we are performing a refresh grant. + if accessRequest.GetGrantTypes().ExactOne("refresh_token") { + // The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable. + // The session, requested scopes, and requested audience from the original authorize request was retrieved + // from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may + // have already been granted on the accessRequest. + err = upstreamRefresh(r.Context(), accessRequest, idpLister) + if err != nil { + plog.Info("upstream refresh error", oidc.FositeErrorForLog(err)...) + oauthHelper.WriteAccessError(w, accessRequest, err) + return nil + } + } + accessResponse, err := oauthHelper.NewAccessResponse(r.Context(), accessRequest) if err != nil { plog.Info("token response error", oidc.FositeErrorForLog(err)...) @@ -39,3 +72,96 @@ func NewHandler( return nil }) } + +func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error { + session := accessRequest.GetSession().(*psession.PinnipedSession) + customSessionData := session.Custom + if customSessionData == nil { + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } + providerName := customSessionData.ProviderName + providerUID := customSessionData.ProviderUID + if providerUID == "" || providerName == "" { + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } + + switch customSessionData.ProviderType { + case psession.ProviderTypeOIDC: + return upstreamOIDCRefresh(ctx, customSessionData, providerCache) + case psession.ProviderTypeLDAP: + // upstream refresh not yet implemented for LDAP, so do nothing + case psession.ProviderTypeActiveDirectory: + // upstream refresh not yet implemented for AD, so do nothing + default: + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } + + return nil +} + +func upstreamOIDCRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister) error { + if s.OIDC == nil || s.OIDC.UpstreamRefreshToken == "" { + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } + + p, err := findOIDCProviderByNameAndValidateUID(s, providerCache) + if err != nil { + return err + } + + plog.Debug("attempting upstream refresh request", + "providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) + + refreshedTokens, err := p.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken) + if err != nil { + return errorsx.WithStack(errUpstreamRefreshError.WithHintf( + "Upstream refresh failed using provider %q of type %q.", + s.ProviderName, s.ProviderType).WithWrap(err)) + } + + // Upstream refresh may or may not return a new ID token. From the spec: + // "the response body is the Token Response of Section 3.1.3.3 except that it might not contain an id_token." + // https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse + _, hasIDTok := refreshedTokens.Extra("id_token").(string) + if hasIDTok { + // The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at + // least some providers do not include one, so we skip the nonce validation here (but not other validations). + _, err = p.ValidateToken(ctx, refreshedTokens, "") + if err != nil { + return errorsx.WithStack(errUpstreamRefreshError.WithHintf( + "Upstream refresh returned an invalid ID token using provider %q of type %q.", + s.ProviderName, s.ProviderType).WithWrap(err)) + } + } else { + plog.Debug("upstream refresh request did not return a new ID token", + "providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) + } + + // Upstream refresh may or may not return a new refresh token. If we got a new refresh token, then update it in + // the user's session. If we did not get a new refresh token, then keep the old one in the session by avoiding + // overwriting the old one. + if refreshedTokens.RefreshToken != "" { + plog.Debug("upstream refresh request did not return a new refresh token", + "providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID) + s.OIDC.UpstreamRefreshToken = refreshedTokens.RefreshToken + } + + return nil +} + +func findOIDCProviderByNameAndValidateUID( + s *psession.CustomSessionData, + providerCache oidc.UpstreamIdentityProvidersLister, +) (provider.UpstreamOIDCIdentityProviderI, error) { + for _, p := range providerCache.GetOIDCIdentityProviders() { + if p.GetName() == s.ProviderName { + if p.GetResourceUID() != s.ProviderUID { + return nil, errorsx.WithStack(errUpstreamRefreshError.WithHint( + "Provider from upstream session data has changed its resource UID since authentication.")) + } + return p, nil + } + } + return nil, errorsx.WithStack(errUpstreamRefreshError. + WithHintf("Provider %q of type %q from upstream session data was not found.", s.ProviderName, s.ProviderType)) +} diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 9032dd10..3b6be1ff 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -23,12 +23,13 @@ import ( "time" "github.com/ory/fosite" - "github.com/ory/fosite/handler/oauth2" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" "github.com/ory/fosite/handler/pkce" "github.com/ory/fosite/token/jwt" "github.com/pkg/errors" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" josejwt "gopkg.in/square/go-jose.v2/jwt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -44,8 +45,11 @@ import ( "go.pinniped.dev/internal/fositestorage/refreshtoken" "go.pinniped.dev/internal/fositestoragei" "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" ) @@ -58,7 +62,6 @@ const ( goodNonce = "some-nonce-value-with-enough-bytes-to-exceed-min-allowed" goodSubject = "https://issuer?sub=some-subject" goodUsername = "some-username" - goodGroups = "group1,groups2" hmacSecret = "this needs to be at least 32 characters to meet entropy requirements" @@ -72,6 +75,8 @@ const ( var ( goodAuthTime = time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC) goodRequestedAtTime = time.Date(7, 6, 5, 4, 3, 2, 1, time.UTC) + goodGroups = []string{"group1", "groups2"} + expectedGoodGroups = []interface{}{"group1", "groups2"} hmacSecretFunc = func() []byte { return []byte(hmacSecret) @@ -177,6 +182,13 @@ var ( } `) + pinnipedUpstreamSessionDataNotFoundErrorBody = here.Doc(` + { + "error": "error", + "error_description": "There was an internal server error. Required upstream data not found in session." + } + `) + happyAuthRequest = &http.Request{ Form: url.Values{ "response_type": {"code"}, @@ -204,12 +216,25 @@ var ( } ) +type expectedUpstreamRefresh struct { + performedByUpstreamName string + args *oidctestutil.PerformRefreshArgs +} + +type expectedUpstreamValidateTokens struct { + performedByUpstreamName string + args *oidctestutil.ValidateTokenArgs +} + type tokenEndpointResponseExpectedValues struct { - wantStatus int - wantSuccessBodyFields []string - wantErrorResponseBody string - wantRequestedScopes []string - wantGrantedScopes []string + wantStatus int + wantSuccessBodyFields []string + wantErrorResponseBody string + wantRequestedScopes []string + wantGrantedScopes []string + wantUpstreamOIDCRefreshCall *expectedUpstreamRefresh + wantUpstreamOIDCValidateTokenCall *expectedUpstreamValidateTokens + wantCustomSessionDataStored *psession.CustomSessionData } type authcodeExchangeInputs struct { @@ -220,13 +245,9 @@ type authcodeExchangeInputs struct { s fositestoragei.AllFositeStorage, authCode string, ) - makeOathHelper func( - t *testing.T, - authRequest *http.Request, - store fositestoragei.AllFositeStorage, - ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) - - want tokenEndpointResponseExpectedValues + makeOathHelper OauthHelperFactoryFunc + customSessionData *psession.CustomSessionData + want tokenEndpointResponseExpectedValues } func TestTokenEndpoint(t *testing.T) { @@ -518,7 +539,8 @@ func TestTokenEndpoint(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - exchangeAuthcodeForTokens(t, test.authcodeExchange) + // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. + exchangeAuthcodeForTokens(t, test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) }) } } @@ -547,7 +569,9 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { t.Parallel() // First call - should be successful. - subject, rsp, authCode, _, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange) + // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. + subject, rsp, authCode, _, secrets, oauthStore := exchangeAuthcodeForTokens(t, + test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) var parsedResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -571,9 +595,11 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { requireInvalidAccessTokenStorage(t, parsedResponseBody, oauthStore) // This was previously invalidated by the first request, so it remains invalidated requireInvalidPKCEStorage(t, authCode, oauthStore) - // Fosite never cleans up OpenID Connect session storage, so it is still there + // Fosite never cleans up OpenID Connect session storage, so it is still there. + // Note that customSessionData is only relevant to refresh grant, so we leave it as nil for this + // authcode exchange test, even though in practice it would actually be in the session. requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, - test.authcodeExchange.want.wantRequestedScopes, test.authcodeExchange.want.wantGrantedScopes) + test.authcodeExchange.want.wantRequestedScopes, test.authcodeExchange.want.wantGrantedScopes, nil) // Check that the access token and refresh token storage were both deleted, and the number of other storage objects did not change. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -600,6 +626,7 @@ func TestTokenExchange(t *testing.T) { }, want: successfulAuthCodeExchange, } + tests := []struct { name string @@ -740,7 +767,9 @@ func TestTokenExchange(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, test.authcodeExchange) + // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. + subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, + test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) var parsedAuthcodeExchangeResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -813,7 +842,7 @@ func TestTokenExchange(t *testing.T) { require.Equal(t, goodSubject, tokenClaims["sub"]) require.Equal(t, goodIssuer, tokenClaims["iss"]) require.Equal(t, goodUsername, tokenClaims["username"]) - require.Equal(t, goodGroups, tokenClaims["groups"]) + require.Equal(t, expectedGoodGroups, tokenClaims["groups"]) // Also assert that some are the same as the original downstream ID token. requireClaimsAreEqual(t, "iss", claimsOfFirstIDToken, tokenClaims) // issuer @@ -843,80 +872,219 @@ type refreshRequestInputs struct { } func TestRefreshGrant(t *testing.T) { + const ( + oidcUpstreamName = "some-oidc-idp" + oidcUpstreamResourceUID = "oidc-resource-uid" + oidcUpstreamType = "oidc" + oidcUpstreamInitialRefreshToken = "initial-upstream-refresh-token" + oidcUpstreamRefreshedIDToken = "fake-refreshed-id-token" + oidcUpstreamRefreshedRefreshToken = "fake-refreshed-refresh-token" + ) + + // The below values are funcs so every test can have its own copy of the objects, to avoid data races + // in these parallel tests. + + upstreamOIDCIdentityProviderBuilder := func() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName(oidcUpstreamName). + WithResourceUID(oidcUpstreamResourceUID) + } + + initialUpstreamOIDCCustomSessionData := func() *psession.CustomSessionData { + return &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: oidcUpstreamInitialRefreshToken, + }, + } + } + + upstreamOIDCCustomSessionDataWithNewRefreshToken := func(newRefreshToken string) *psession.CustomSessionData { + sessionData := initialUpstreamOIDCCustomSessionData() + sessionData.OIDC.UpstreamRefreshToken = newRefreshToken + return sessionData + } + + happyUpstreamRefreshCall := func() *expectedUpstreamRefresh { + return &expectedUpstreamRefresh{ + performedByUpstreamName: oidcUpstreamName, + args: &oidctestutil.PerformRefreshArgs{ + Ctx: nil, // this will be filled in with the actual request context by the test below + RefreshToken: oidcUpstreamInitialRefreshToken, + }, + } + } + + happyUpstreamValidateTokenCall := func(expectedTokens *oauth2.Token) *expectedUpstreamValidateTokens { + return &expectedUpstreamValidateTokens{ + performedByUpstreamName: oidcUpstreamName, + args: &oidctestutil.ValidateTokenArgs{ + Ctx: nil, // this will be filled in with the actual request context by the test below + Tok: expectedTokens, + ExpectedIDTokenNonce: "", // always expect empty string + }, + } + } + + happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues { + want := tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + wantCustomSessionDataStored: wantCustomSessionDataStored, + } + return want + } + + happyRefreshTokenResponseForOpenIDAndOfflineAccess := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token) tokenEndpointResponseExpectedValues { + // Should always have some custom session data stored. The other expectations happens to be the + // same as the same values as the authcode exchange case. + want := happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(wantCustomSessionDataStored) + // Should always try to perform an upstream refresh. + want.wantUpstreamOIDCRefreshCall = happyUpstreamRefreshCall() + // Should only try to ValidateToken when there was an id token returned by the upstream refresh. + if expectToValidateToken != nil { + want.wantUpstreamOIDCValidateTokenCall = happyUpstreamValidateTokenCall(expectToValidateToken) + } + return want + } + + refreshedUpstreamTokensWithRefreshTokenWithoutIDToken := func() *oauth2.Token { + return &oauth2.Token{ + AccessToken: "fake-refreshed-access-token", + TokenType: "Bearer", + RefreshToken: oidcUpstreamRefreshedRefreshToken, + Expiry: time.Date(2050, 1, 1, 1, 1, 1, 1, time.UTC), + } + } + + refreshedUpstreamTokensWithIDAndRefreshTokens := func() *oauth2.Token { + return refreshedUpstreamTokensWithRefreshTokenWithoutIDToken(). + WithExtra(map[string]interface{}{"id_token": oidcUpstreamRefreshedIDToken}) + } + + refreshedUpstreamTokensWithIDTokenWithoutRefreshToken := func() *oauth2.Token { + tokens := refreshedUpstreamTokensWithIDAndRefreshTokens() + tokens.RefreshToken = "" // remove the refresh token + return tokens + } + tests := []struct { name string + idps *oidctestutil.UpstreamIDPListerBuilder authcodeExchange authcodeExchangeInputs refreshRequest refreshRequestInputs }{ { - name: "happy path refresh grant with ID token", + name: "happy path refresh grant with openid scope granted (id token returned)", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), }, refreshRequest: refreshRequestInputs{ - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }}, + want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + refreshedUpstreamTokensWithIDAndRefreshTokens(), + ), + }, }, { - name: "happy path refresh grant without ID token", + name: "happy path refresh grant without openid scope granted (no id token returned)", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), }, }, refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, - }}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), + wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + }, + }, + }, + { + name: "happy path refresh grant when the upstream refresh does not return a new ID token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithRefreshTokenWithoutIDToken()).Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), + }, + refreshRequest: refreshRequestInputs{ + want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + nil, // expect ValidateToken is *not* called + ), + }, + }, + { + name: "happy path refresh grant when the upstream refresh does not return a new refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDTokenWithoutRefreshToken()).Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), + }, + refreshRequest: refreshRequestInputs{ + want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( + initialUpstreamOIDCCustomSessionData(), // still has the initial refresh token stored + refreshedUpstreamTokensWithIDTokenWithoutRefreshToken(), + ), + }, }, { name: "when the refresh request adds a new scope to the list of requested scopes then it is ignored", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), }, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { r.Body = happyRefreshRequestBody(refreshToken).WithScope("openid some-other-scope-not-from-auth-request").ReadCloser() }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }}, + want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + refreshedUpstreamTokensWithIDAndRefreshTokens(), + ), + }, }, { name: "when the refresh request removes a scope which was originally granted from the list of requested scopes then it is granted anyway", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience") }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, - wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), }, }, refreshRequest: refreshRequestInputs{ @@ -924,43 +1092,47 @@ func TestRefreshGrant(t *testing.T) { r.Body = happyRefreshRequestBody(refreshToken).WithScope("openid").ReadCloser() // do not ask for "pinniped:request-audience" again }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, - wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, - }}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), + wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + }, + }, }, { name: "when the refresh request does not include a scope param then it gets all the same scopes as the original authorization request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), }, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { r.Body = happyRefreshRequestBody(refreshToken).WithScope("").ReadCloser() }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, - }}, + want: happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + refreshedUpstreamTokensWithIDAndRefreshTokens(), + ), + }, }, { name: "when a bad refresh token is sent in the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), }, }, refreshRequest: refreshRequestInputs{ @@ -970,17 +1142,21 @@ func TestRefreshGrant(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, - }}, + }, + }, }, { name: "when the access token is sent as if it were a refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), }, }, refreshRequest: refreshRequestInputs{ @@ -990,17 +1166,21 @@ func TestRefreshGrant(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, - }}, + }, + }, }, { name: "when the wrong client ID is included in the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantCustomSessionDataStored: initialUpstreamOIDCCustomSessionData(), }, }, refreshRequest: refreshRequestInputs{ @@ -1010,7 +1190,301 @@ func TestRefreshGrant(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusUnauthorized, wantErrorResponseBody: fositeInvalidClientErrorBody, - }}, + }, + }, + }, + { + name: "when there is no custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: nil, // this should not happen in practice + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(nil), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is no provider name in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: "", // this should not happen in practice + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: "", // this should not happen in practice + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is no provider UID in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: "", // this should not happen in practice + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: "", // this should not happen in practice + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is no provider type in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: "", // this should not happen in practice + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: "", // this should not happen in practice + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is an illegal provider type in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: "not-an-allowed-provider-type", // this should not happen in practice + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: "not-an-allowed-provider-type", // this should not happen in practice + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is no OIDC-specific data in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: nil, // this should not happen in practice + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: nil, // this should not happen in practice + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when there is no OIDC refresh token in custom session data found in the session storage during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: "", // this should not happen in practice + }, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: "", // this should not happen in practice + }, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: pinnipedUpstreamSessionDataNotFoundErrorBody, + }, + }, + }, + { + name: "when the provider in the session storage is not found due to its name during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: "this-name-will-not-be-found", // this could happen if the OIDCIdentityProvider was deleted since original login + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: "this-name-will-not-be-found", // this could happen if the OIDCIdentityProvider was deleted since original login + ProviderUID: oidcUpstreamResourceUID, + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider 'this-name-will-not-be-found' of type 'oidc' from upstream session data was not found." + } + `), + }, + }, + }, + { + name: "when the provider in the session storage is found but has the wrong resource UID during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: &psession.CustomSessionData{ + ProviderName: oidcUpstreamName, + ProviderUID: "this is the wrong uid", // this could happen if the OIDCIdentityProvider was deleted and recreated at the same name since original login + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + &psession.CustomSessionData{ // want the initial customSessionData to be unmodified + ProviderName: oidcUpstreamName, + ProviderUID: "this is the wrong uid", // this could happen if the OIDCIdentityProvider was deleted and recreated at the same name since original login + ProviderType: oidcUpstreamType, + OIDC: &psession.OIDCSessionData{UpstreamRefreshToken: oidcUpstreamInitialRefreshToken}, + }, + ), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." + } + `), + }, + }, + }, + { + name: "when the upstream refresh fails during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). + WithPerformRefreshError(errors.New("some upstream refresh error")).Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Upstream refresh failed using provider 'some-oidc-idp' of type 'oidc'." + } + `), + }, + }, + }, + { + name: "when the upstream refresh returns an invalid ID token during the refresh request", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder(). + WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()). + // This is the current format of the errors returned by the production code version of ValidateToken, see ValidateToken in upstreamoidc.go + WithValidateTokenError(httperr.Wrap(http.StatusBadRequest, "some validate error", errors.New("some validate cause"))). + Build()), + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCCustomSessionData()), + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantUpstreamOIDCRefreshCall: happyUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens()), + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "Error during upstream refresh. Upstream refresh returned an invalid ID token using provider 'some-oidc-idp' of type 'oidc'." + } + `), + }, + }, }, } for _, test := range tests { @@ -1019,10 +1493,15 @@ func TestRefreshGrant(t *testing.T) { t.Parallel() // First exchange the authcode for tokens, including a refresh token. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange) + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.Build()) var parsedAuthcodeExchangeResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) + // Performing an authcode exchange should not have caused any upstream refresh, which should only + // happen during a downstream refresh. + test.idps.RequireExactlyZeroCallsToPerformRefresh(t) + test.idps.RequireExactlyZeroCallsToValidateToken(t) + // Wait one second before performing the refresh so we can see that the refreshed ID token has new issued // at and expires at dates which are newer than the old tokens. // If this gets too annoying in terms of making our test suite slower then we can remove it and adjust @@ -1031,8 +1510,10 @@ func TestRefreshGrant(t *testing.T) { // Send the refresh token back and preform a refresh. firstRefreshToken := parsedAuthcodeExchangeResponseBody["refresh_token"].(string) + require.NotEmpty(t, firstRefreshToken) + reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") req := httptest.NewRequest("POST", "/path/shouldn't/matter", - happyRefreshRequestBody(firstRefreshToken).ReadCloser()) + happyRefreshRequestBody(firstRefreshToken).ReadCloser()).WithContext(reqContext) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if test.refreshRequest.modifyTokenRequest != nil { test.refreshRequest.modifyTokenRequest(req, firstRefreshToken, parsedAuthcodeExchangeResponseBody["access_token"].(string)) @@ -1043,11 +1524,45 @@ func TestRefreshGrant(t *testing.T) { t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) + // Test that we did or did not make a call to the upstream OIDC provider interface to perform a token refresh. + if test.refreshRequest.want.wantUpstreamOIDCRefreshCall != nil { + test.refreshRequest.want.wantUpstreamOIDCRefreshCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToPerformRefresh(t, + test.refreshRequest.want.wantUpstreamOIDCRefreshCall.performedByUpstreamName, + test.refreshRequest.want.wantUpstreamOIDCRefreshCall.args, + ) + } else { + test.idps.RequireExactlyZeroCallsToPerformRefresh(t) + } + + // Test that we did or did not make a call to the upstream OIDC provider interface to validate the + // new ID token that was returned by the upstream refresh. + if test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall != nil { + test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args.Ctx = reqContext + test.idps.RequireExactlyOneCallToValidateToken(t, + test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.performedByUpstreamName, + test.refreshRequest.want.wantUpstreamOIDCValidateTokenCall.args, + ) + } else { + test.idps.RequireExactlyZeroCallsToValidateToken(t) + } + // The bug in fosite that prevents at_hash from appearing in the initial ID token does not impact the refreshed ID token wantAtHashClaimInIDToken := true // Refreshed ID tokens do not include the nonce from the original auth request wantNonceValueInIDToken := false - requireTokenEndpointBehavior(t, test.refreshRequest.want, wantAtHashClaimInIDToken, wantNonceValueInIDToken, refreshResponse, authCode, oauthStore, jwtSigningKey, secrets) + + requireTokenEndpointBehavior(t, + test.refreshRequest.want, + test.authcodeExchange.customSessionData, + wantAtHashClaimInIDToken, + wantNonceValueInIDToken, + refreshResponse, + authCode, + oauthStore, + jwtSigningKey, + secrets, + ) if test.refreshRequest.want.wantStatus == http.StatusOK { wantIDToken := contains(test.refreshRequest.want.wantSuccessBodyFields, "id_token") @@ -1107,7 +1622,7 @@ func requireClaimsAreEqual(t *testing.T, claimName string, claimsOfTokenA map[st require.Equal(t, claimsOfTokenA[claimName], claimsOfTokenB[claimName]) } -func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( +func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps provider.DynamicUpstreamIDPProvider) ( subject http.Handler, rsp *httptest.ResponseRecorder, authCode string, @@ -1127,15 +1642,17 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( oauthStore = oidc.NewKubeStorage(secrets, oidc.DefaultOIDCTimeoutsConfiguration()) if test.makeOathHelper != nil { - oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore) + oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore, test.customSessionData) } else { - oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore) + // Note that makeHappyOauthHelper() calls simulateAuthEndpointHavingAlreadyRun() to preload the session storage. + oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore, test.customSessionData) } if test.modifyStorage != nil { test.modifyStorage(t, oauthStore, authCode) } - subject = NewHandler(oauthHelper) + + subject = NewHandler(idps, oauthHelper) authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid") expectedNumberOfIDSessionsStored := 0 @@ -1161,7 +1678,18 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( wantAtHashClaimInIDToken := false // due to a bug in fosite, the at_hash claim is not filled in during authcode exchange wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unliked refreshed ID tokens) - requireTokenEndpointBehavior(t, test.want, wantAtHashClaimInIDToken, wantNonceValueInIDToken, rsp, authCode, oauthStore, jwtSigningKey, secrets) + + requireTokenEndpointBehavior(t, + test.want, + test.customSessionData, + wantAtHashClaimInIDToken, + wantNonceValueInIDToken, + rsp, + authCode, + oauthStore, + jwtSigningKey, + secrets, + ) return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore } @@ -1169,6 +1697,7 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( func requireTokenEndpointBehavior( t *testing.T, test tokenEndpointResponseExpectedValues, + oldCustomSessionData *psession.CustomSessionData, wantAtHashClaimInIDToken bool, wantNonceValueInIDToken bool, tokenEndpointResponse *httptest.ResponseRecorder, @@ -1191,9 +1720,10 @@ func requireTokenEndpointBehavior( wantRefreshToken := contains(test.wantSuccessBodyFields, "refresh_token") requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets) - requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, secrets) + requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, test.wantCustomSessionDataStored, secrets) requireInvalidPKCEStorage(t, authCode, oauthStore) - requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) + // Performing a refresh does not update the OIDC storage, so after a refresh it should still have the old custom session data from the initial login. + requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, oldCustomSessionData) expectedNumberOfRefreshTokenSessionsStored := 0 if wantRefreshToken { @@ -1205,7 +1735,7 @@ func requireTokenEndpointBehavior( requireValidIDToken(t, parsedResponseBody, jwtSigningKey, wantAtHashClaimInIDToken, wantNonceValueInIDToken, parsedResponseBody["access_token"].(string)) } if wantRefreshToken { - requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, secrets) + requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, test.wantCustomSessionDataStored, secrets) } testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -1302,16 +1832,24 @@ func getFositeDataSignature(t *testing.T, data string) string { return split[1] } +type OauthHelperFactoryFunc func( + t *testing.T, + authRequest *http.Request, + store fositestoragei.AllFositeStorage, + initialCustomSessionData *psession.CustomSessionData, +) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) + func makeHappyOauthHelper( t *testing.T, authRequest *http.Request, store fositestoragei.AllFositeStorage, + initialCustomSessionData *psession.CustomSessionData, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() jwtSigningKey, jwkProvider := generateJWTSigningKeyAndJWKSProvider(t, goodIssuer) oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration()) - authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper) + authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper, initialCustomSessionData) return oauthHelper, authResponder.GetCode(), jwtSigningKey } @@ -1332,12 +1870,13 @@ func makeOauthHelperWithJWTKeyThatWorksOnlyOnce( t *testing.T, authRequest *http.Request, store fositestoragei.AllFositeStorage, + initialCustomSessionData *psession.CustomSessionData, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() jwtSigningKey, jwkProvider := generateJWTSigningKeyAndJWKSProvider(t, goodIssuer) oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, &singleUseJWKProvider{DynamicJWKSProvider: jwkProvider}, oidc.DefaultOIDCTimeoutsConfiguration()) - authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper) + authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper, initialCustomSessionData) return oauthHelper, authResponder.GetCode(), jwtSigningKey } @@ -1345,31 +1884,40 @@ func makeOauthHelperWithNilPrivateJWTSigningKey( t *testing.T, authRequest *http.Request, store fositestoragei.AllFositeStorage, + initialCustomSessionData *psession.CustomSessionData, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { t.Helper() jwkProvider := jwks.NewDynamicJWKSProvider() // empty provider which contains no signing key for this issuer oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, hmacSecretFunc, jwkProvider, oidc.DefaultOIDCTimeoutsConfiguration()) - authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper) + authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper, initialCustomSessionData) return oauthHelper, authResponder.GetCode(), nil } // Simulate the auth endpoint running so Fosite code will fill the store with realistic values. -func simulateAuthEndpointHavingAlreadyRun(t *testing.T, authRequest *http.Request, oauthHelper fosite.OAuth2Provider) fosite.AuthorizeResponder { +func simulateAuthEndpointHavingAlreadyRun( + t *testing.T, + authRequest *http.Request, + oauthHelper fosite.OAuth2Provider, + initialCustomSessionData *psession.CustomSessionData, +) fosite.AuthorizeResponder { // We only set the fields in the session that Fosite wants us to set. ctx := context.Background() - session := &openid.DefaultSession{ - Claims: &jwt.IDTokenClaims{ - Subject: goodSubject, - RequestedAt: goodRequestedAtTime, - AuthTime: goodAuthTime, - Extra: map[string]interface{}{ - oidc.DownstreamUsernameClaim: goodUsername, - oidc.DownstreamGroupsClaim: goodGroups, + session := &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{ + Subject: goodSubject, + RequestedAt: goodRequestedAtTime, + AuthTime: goodAuthTime, + Extra: map[string]interface{}{ + oidc.DownstreamUsernameClaim: goodUsername, + oidc.DownstreamGroupsClaim: goodGroups, + }, }, + Subject: "", // not used, note that callback_handler.go does not set this + Username: "", // not used, note that callback_handler.go does not set this }, - Subject: "", // not used, note that callback_handler.go does not set this - Username: "", // not used, note that callback_handler.go does not set this + Custom: initialCustomSessionData, } authRequester, err := oauthHelper.NewAuthorizeRequest(ctx, authRequest) require.NoError(t, err) @@ -1407,7 +1955,7 @@ func generateJWTSigningKeyAndJWKSProvider(t *testing.T, issuer string) (*ecdsa.P func requireInvalidAuthCodeStorage( t *testing.T, code string, - storage oauth2.CoreStorage, + storage fositeoauth2.CoreStorage, secrets v1.SecretInterface, ) { t.Helper() @@ -1422,9 +1970,10 @@ func requireInvalidAuthCodeStorage( func requireValidRefreshTokenStorage( t *testing.T, body map[string]interface{}, - storage oauth2.CoreStorage, + storage fositeoauth2.CoreStorage, wantRequestedScopes []string, wantGrantedScopes []string, + wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, ) { t.Helper() @@ -1446,6 +1995,7 @@ func requireValidRefreshTokenStorage( wantRequestedScopes, wantGrantedScopes, true, + wantCustomSessionData, ) requireGarbageCollectTimeInDelta(t, refreshTokenString, "refresh-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) @@ -1454,9 +2004,10 @@ func requireValidRefreshTokenStorage( func requireValidAccessTokenStorage( t *testing.T, body map[string]interface{}, - storage oauth2.CoreStorage, + storage fositeoauth2.CoreStorage, wantRequestedScopes []string, wantGrantedScopes []string, + wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, ) { t.Helper() @@ -1497,6 +2048,7 @@ func requireValidAccessTokenStorage( wantRequestedScopes, wantGrantedScopes, true, + wantCustomSessionData, ) requireGarbageCollectTimeInDelta(t, accessTokenString, "access-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) @@ -1505,7 +2057,7 @@ func requireValidAccessTokenStorage( func requireInvalidAccessTokenStorage( t *testing.T, body map[string]interface{}, - storage oauth2.CoreStorage, + storage fositeoauth2.CoreStorage, ) { t.Helper() @@ -1538,6 +2090,7 @@ func requireValidOIDCStorage( storage openid.OpenIDConnectRequestStorage, wantRequestedScopes []string, wantGrantedScopes []string, + wantCustomSessionData *psession.CustomSessionData, ) { t.Helper() @@ -1560,6 +2113,7 @@ func requireValidOIDCStorage( wantRequestedScopes, wantGrantedScopes, false, + wantCustomSessionData, ) } else { _, err := storage.GetOpenIDConnectSession(context.Background(), code, nil) @@ -1574,6 +2128,7 @@ func requireValidStoredRequest( wantRequestedScopes []string, wantGrantedScopes []string, wantAccessTokenExpiresAt bool, + wantCustomSessionData *psession.CustomSessionData, ) { t.Helper() @@ -1588,19 +2143,19 @@ func requireValidStoredRequest( require.Equal(t, wantRequestForm, request.GetRequestForm()) // Fosite stores access token request without form // Cast session to the type we think it should be. - session, ok := request.GetSession().(*openid.DefaultSession) - require.Truef(t, ok, "could not cast %T to %T", request.GetSession(), &openid.DefaultSession{}) + session, ok := request.GetSession().(*psession.PinnipedSession) + require.Truef(t, ok, "could not cast %T to %T", request.GetSession(), &psession.PinnipedSession{}) // Assert that the session claims are what we think they should be, but only if we are doing OIDC. if contains(wantGrantedScopes, "openid") { - claims := session.Claims + claims := session.Fosite.Claims require.Empty(t, claims.JTI) // When claims.JTI is empty, Fosite will generate a UUID for this field. require.Equal(t, goodSubject, claims.Subject) // Our custom claims from the authorize endpoint should still be set. require.Equal(t, map[string]interface{}{ "username": goodUsername, - "groups": goodGroups, + "groups": expectedGoodGroups, }, claims.Extra) // We are in charge of setting these fields. For the purpose of testing, we ensure that the @@ -1610,7 +2165,7 @@ func requireValidStoredRequest( // These fields will all be given good defaults by fosite at runtime and we only need to use them // if we want to override the default behaviors. We currently don't need to override these defaults, - // so they do not end up being stored. Fosite sets its defaults at runtime in openid.DefaultSession's + // so they do not end up being stored. Fosite sets its defaults at runtime in openid.DefaultStrategy's // GenerateIDToken() method. require.Empty(t, claims.Issuer) require.Empty(t, claims.Audience) @@ -1630,11 +2185,11 @@ func requireValidStoredRequest( } // Assert that the session headers are what we think they should be. - headers := session.Headers + headers := session.Fosite.Headers require.Empty(t, headers) // Assert that the token expirations are what we think they should be. - authCodeExpiresAt, ok := session.ExpiresAt[fosite.AuthorizeCode] + authCodeExpiresAt, ok := session.Fosite.ExpiresAt[fosite.AuthorizeCode] require.True(t, ok, "expected session to hold expiration time for auth code") testutil.RequireTimeInDelta( t, @@ -1644,7 +2199,7 @@ func requireValidStoredRequest( ) // OpenID Connect sessions do not store access token expiration information. - accessTokenExpiresAt, ok := session.ExpiresAt[fosite.AccessToken] + accessTokenExpiresAt, ok := session.Fosite.ExpiresAt[fosite.AccessToken] if wantAccessTokenExpiresAt { require.True(t, ok, "expected session to hold expiration time for access token") testutil.RequireTimeInDelta( @@ -1658,8 +2213,11 @@ func requireValidStoredRequest( } // We don't use these, so they should be empty. - require.Empty(t, session.Username) - require.Empty(t, session.Subject) + require.Empty(t, session.Fosite.Username) + require.Empty(t, session.Fosite.Subject) + + // The custom session data was stored as expected. + require.Equal(t, wantCustomSessionData, session.Custom) } func requireGarbageCollectTimeInDelta(t *testing.T, tokenString string, typeLabel string, secrets v1.SecretInterface, wantExpirationTime time.Time, deltaTime time.Duration) { @@ -1709,7 +2267,7 @@ func requireValidIDToken( IssuedAt int64 `json:"iat"` RequestedAt int64 `json:"rat"` AuthTime int64 `json:"auth_time"` - Groups string `json:"groups"` + Groups []string `json:"groups"` Username string `json:"username"` } diff --git a/internal/psession/pinniped_session.go b/internal/psession/pinniped_session.go new file mode 100644 index 00000000..72ea3bdb --- /dev/null +++ b/internal/psession/pinniped_session.go @@ -0,0 +1,108 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package psession + +import ( + "time" + + "github.com/mohae/deepcopy" + "github.com/ory/fosite" + "github.com/ory/fosite/handler/openid" + "github.com/ory/fosite/token/jwt" + "k8s.io/apimachinery/pkg/types" +) + +// PinnipedSession is a session container which includes the fosite standard stuff plus custom Pinniped stuff. +type PinnipedSession struct { + // Delegate most things to the standard fosite OpenID JWT session. + Fosite *openid.DefaultSession `json:"fosite,omitempty"` + + // Custom Pinniped extensions to the session data. + Custom *CustomSessionData `json:"custom,omitempty"` +} + +var _ openid.Session = &PinnipedSession{} + +// CustomSessionData is the custom session data needed by Pinniped. It should be treated as a union type, +// where the value of ProviderType decides which other fields to use. +type CustomSessionData struct { + // The Kubernetes resource UID of the identity provider CRD for the upstream IDP used to start this session. + // This should be validated again upon downstream refresh to make sure that we are not refreshing against + // a different identity provider CRD which just happens to have the same name. + // This implies that when a user deletes an identity provider CRD, then the sessions that were started + // using that identity provider will not be able to perform any more downstream refreshes. + ProviderUID types.UID `json:"providerUID"` + + // The Kubernetes resource name of the identity provider CRD for the upstream IDP used to start this session. + // Used during a downstream refresh to decide which upstream to refresh. + // Also used to decide which of the pointer types below should be used. + ProviderName string `json:"providerName"` + + // The type of the identity provider for the upstream IDP used to start this session. + // Used during a downstream refresh to decide which upstream to refresh. + ProviderType ProviderType `json:"providerType"` + + // Only used when ProviderType == "oidc". + OIDC *OIDCSessionData `json:"oidc,omitempty"` +} + +type ProviderType string + +const ( + ProviderTypeOIDC ProviderType = "oidc" + ProviderTypeLDAP ProviderType = "ldap" + ProviderTypeActiveDirectory ProviderType = "activedirectory" +) + +// OIDCSessionData is the additional data needed by Pinniped when the upstream IDP is an OIDC provider. +type OIDCSessionData struct { + UpstreamRefreshToken string `json:"upstreamRefreshToken"` +} + +// NewPinnipedSession returns a new empty session. +func NewPinnipedSession() *PinnipedSession { + return &PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: &jwt.IDTokenClaims{}, + Headers: &jwt.Headers{}, + }, + Custom: &CustomSessionData{}, + } +} + +func (s *PinnipedSession) Clone() fosite.Session { + // Implementation copied from openid.DefaultSession's clone method. + if s == nil { + return nil + } + return deepcopy.Copy(s).(fosite.Session) +} + +func (s *PinnipedSession) SetExpiresAt(key fosite.TokenType, exp time.Time) { + s.Fosite.SetExpiresAt(key, exp) +} + +func (s *PinnipedSession) GetExpiresAt(key fosite.TokenType) time.Time { + return s.Fosite.GetExpiresAt(key) +} + +func (s *PinnipedSession) GetUsername() string { + return s.Fosite.GetUsername() +} + +func (s *PinnipedSession) SetSubject(subject string) { + s.Fosite.SetSubject(subject) +} + +func (s *PinnipedSession) GetSubject() string { + return s.Fosite.GetSubject() +} + +func (s *PinnipedSession) IDTokenHeaders() *jwt.Headers { + return s.Fosite.IDTokenHeaders() +} + +func (s *PinnipedSession) IDTokenClaims() *jwt.IDTokenClaims { + return s.Fosite.IDTokenClaims() +} diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 5666e610..90361ddd 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -16,11 +16,11 @@ import ( coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/fosite" - "github.com/ory/fosite/handler/openid" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" @@ -31,6 +31,7 @@ import ( pkce2 "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestoragei" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -57,14 +58,34 @@ type PasswordCredentialsGrantAndValidateTokensArgs struct { Password string } +// PerformRefreshArgs is used to spy on calls to +// TestUpstreamOIDCIdentityProvider.PerformRefreshFunc(). +type PerformRefreshArgs struct { + Ctx context.Context + RefreshToken string +} + +// ValidateTokenArgs is used to spy on calls to +// TestUpstreamOIDCIdentityProvider.ValidateTokenFunc(). +type ValidateTokenArgs struct { + Ctx context.Context + Tok *oauth2.Token + ExpectedIDTokenNonce nonce.Nonce +} + type TestUpstreamLDAPIdentityProvider struct { Name string + ResourceUID types.UID URL *url.URL AuthenticateFunc func(ctx context.Context, username, password string) (*authenticator.Response, bool, error) } var _ provider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{} +func (u *TestUpstreamLDAPIdentityProvider) GetResourceUID() types.UID { + return u.ResourceUID +} + func (u *TestUpstreamLDAPIdentityProvider) GetName() string { return u.Name } @@ -78,13 +99,15 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { } type TestUpstreamOIDCIdentityProvider struct { - Name string - ClientID string - AuthorizationURL url.URL - UsernameClaim string - GroupsClaim string - Scopes []string - AllowPasswordGrant bool + Name string + ClientID string + ResourceUID types.UID + AuthorizationURL url.URL + UsernameClaim string + GroupsClaim string + Scopes []string + AdditionalAuthcodeParams map[string]string + AllowPasswordGrant bool ExchangeAuthcodeAndValidateTokensFunc func( ctx context.Context, @@ -99,10 +122,28 @@ type TestUpstreamOIDCIdentityProvider struct { password string, ) (*oidctypes.Token, error) + PerformRefreshFunc func(ctx context.Context, refreshToken string) (*oauth2.Token, error) + + ValidateTokenFunc func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) + exchangeAuthcodeAndValidateTokensCallCount int exchangeAuthcodeAndValidateTokensArgs []*ExchangeAuthcodeAndValidateTokenArgs passwordCredentialsGrantAndValidateTokensCallCount int passwordCredentialsGrantAndValidateTokensArgs []*PasswordCredentialsGrantAndValidateTokensArgs + performRefreshCallCount int + performRefreshArgs []*PerformRefreshArgs + validateTokenCallCount int + validateTokenArgs []*ValidateTokenArgs +} + +var _ provider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{} + +func (u *TestUpstreamOIDCIdentityProvider) GetResourceUID() types.UID { + return u.ResourceUID +} + +func (u *TestUpstreamOIDCIdentityProvider) GetAdditionalAuthcodeParams() map[string]string { + return u.AdditionalAuthcodeParams } func (u *TestUpstreamOIDCIdentityProvider) GetName() string { @@ -175,8 +216,51 @@ func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokensArgs return u.exchangeAuthcodeAndValidateTokensArgs[call] } -func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(_ context.Context, _ *oauth2.Token, _ nonce.Nonce) (*oidctypes.Token, error) { - panic("implement me") +func (u *TestUpstreamOIDCIdentityProvider) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + if u.performRefreshArgs == nil { + u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + } + u.performRefreshCallCount++ + u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ + Ctx: ctx, + RefreshToken: refreshToken, + }) + return u.PerformRefreshFunc(ctx, refreshToken) +} + +func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshCallCount() int { + return u.performRefreshCallCount +} + +func (u *TestUpstreamOIDCIdentityProvider) PerformRefreshArgs(call int) *PerformRefreshArgs { + if u.performRefreshArgs == nil { + u.performRefreshArgs = make([]*PerformRefreshArgs, 0) + } + return u.performRefreshArgs[call] +} + +func (u *TestUpstreamOIDCIdentityProvider) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { + if u.validateTokenArgs == nil { + u.validateTokenArgs = make([]*ValidateTokenArgs, 0) + } + u.validateTokenCallCount++ + u.validateTokenArgs = append(u.validateTokenArgs, &ValidateTokenArgs{ + Ctx: ctx, + Tok: tok, + ExpectedIDTokenNonce: expectedIDTokenNonce, + }) + return u.ValidateTokenFunc(ctx, tok, expectedIDTokenNonce) +} + +func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenCallCount() int { + return u.validateTokenCallCount +} + +func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenArgs(call int) *ValidateTokenArgs { + if u.validateTokenArgs == nil { + u.validateTokenArgs = make([]*ValidateTokenArgs, 0) + } + return u.validateTokenArgs[call] } type UpstreamIDPListerBuilder struct { @@ -298,21 +382,102 @@ func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToExchangeAuthcodeAndV ) } +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToPerformRefresh( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *PerformRefreshArgs, +) { + t.Helper() + var actualArgs *PerformRefreshArgs + var actualNameOfUpstreamWhichMadeCall string + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstreamOIDC.performRefreshCallCount + actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name + actualArgs = upstreamOIDC.performRefreshArgs[0] + } + } + require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, + "should have been exactly one call to PerformRefresh() by all OIDC upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "PerformRefresh() was called on the wrong OIDC upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToPerformRefresh(t *testing.T) { + t.Helper() + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.performRefreshCallCount + } + require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, + "expected exactly zero calls to PerformRefresh()", + ) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyOneCallToValidateToken( + t *testing.T, + expectedPerformedByUpstreamName string, + expectedArgs *ValidateTokenArgs, +) { + t.Helper() + var actualArgs *ValidateTokenArgs + var actualNameOfUpstreamWhichMadeCall string + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + callCountOnThisUpstream := upstreamOIDC.validateTokenCallCount + actualCallCountAcrossAllOIDCUpstreams += callCountOnThisUpstream + if callCountOnThisUpstream == 1 { + actualNameOfUpstreamWhichMadeCall = upstreamOIDC.Name + actualArgs = upstreamOIDC.validateTokenArgs[0] + } + } + require.Equal(t, 1, actualCallCountAcrossAllOIDCUpstreams, + "should have been exactly one call to ValidateToken() by all OIDC upstreams", + ) + require.Equal(t, expectedPerformedByUpstreamName, actualNameOfUpstreamWhichMadeCall, + "ValidateToken() was called on the wrong OIDC upstream", + ) + require.Equal(t, expectedArgs, actualArgs) +} + +func (b *UpstreamIDPListerBuilder) RequireExactlyZeroCallsToValidateToken(t *testing.T) { + t.Helper() + actualCallCountAcrossAllOIDCUpstreams := 0 + for _, upstreamOIDC := range b.upstreamOIDCIdentityProviders { + actualCallCountAcrossAllOIDCUpstreams += upstreamOIDC.validateTokenCallCount + } + require.Equal(t, 0, actualCallCountAcrossAllOIDCUpstreams, + "expected exactly zero calls to ValidateToken()", + ) +} + func NewUpstreamIDPListerBuilder() *UpstreamIDPListerBuilder { return &UpstreamIDPListerBuilder{} } type TestUpstreamOIDCIdentityProviderBuilder struct { - name string - clientID string - scopes []string - idToken map[string]interface{} - usernameClaim string - groupsClaim string - authorizationURL url.URL - allowPasswordGrant bool - authcodeExchangeErr error - passwordGrantErr error + name string + resourceUID types.UID + clientID string + scopes []string + idToken map[string]interface{} + refreshToken *oidctypes.RefreshToken + usernameClaim string + groupsClaim string + refreshedTokens *oauth2.Token + validatedTokens *oidctypes.Token + authorizationURL url.URL + additionalAuthcodeParams map[string]string + allowPasswordGrant bool + authcodeExchangeErr error + passwordGrantErr error + performRefreshErr error + validateTokenErr error } func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder { @@ -320,6 +485,11 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUp return u } +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithResourceUID(value types.UID) *TestUpstreamOIDCIdentityProviderBuilder { + u.resourceUID = value + return u +} + func (u *TestUpstreamOIDCIdentityProviderBuilder) WithClientID(value string) *TestUpstreamOIDCIdentityProviderBuilder { u.clientID = value return u @@ -373,6 +543,26 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutIDTokenClaim(claim stri return u } +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithAdditionalAuthcodeParams(params map[string]string) *TestUpstreamOIDCIdentityProviderBuilder { + u.additionalAuthcodeParams = params + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithRefreshToken(token string) *TestUpstreamOIDCIdentityProviderBuilder { + u.refreshToken = &oidctypes.RefreshToken{Token: token} + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithEmptyRefreshToken() *TestUpstreamOIDCIdentityProviderBuilder { + u.refreshToken = &oidctypes.RefreshToken{Token: ""} + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithoutRefreshToken() *TestUpstreamOIDCIdentityProviderBuilder { + u.refreshToken = nil + return u +} + func (u *TestUpstreamOIDCIdentityProviderBuilder) WithUpstreamAuthcodeExchangeError(err error) *TestUpstreamOIDCIdentityProviderBuilder { u.authcodeExchangeErr = err return u @@ -383,26 +573,60 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithPasswordGrantError(err err return u } +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithRefreshedTokens(tokens *oauth2.Token) *TestUpstreamOIDCIdentityProviderBuilder { + u.refreshedTokens = tokens + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithPerformRefreshError(err error) *TestUpstreamOIDCIdentityProviderBuilder { + u.performRefreshErr = err + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidatedTokens(tokens *oidctypes.Token) *TestUpstreamOIDCIdentityProviderBuilder { + u.validatedTokens = tokens + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithValidateTokenError(err error) *TestUpstreamOIDCIdentityProviderBuilder { + u.validateTokenErr = err + return u +} + func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdentityProvider { return &TestUpstreamOIDCIdentityProvider{ - Name: u.name, - ClientID: u.clientID, - UsernameClaim: u.usernameClaim, - GroupsClaim: u.groupsClaim, - Scopes: u.scopes, - AllowPasswordGrant: u.allowPasswordGrant, - AuthorizationURL: u.authorizationURL, + Name: u.name, + ClientID: u.clientID, + ResourceUID: u.resourceUID, + UsernameClaim: u.usernameClaim, + GroupsClaim: u.groupsClaim, + Scopes: u.scopes, + AllowPasswordGrant: u.allowPasswordGrant, + AuthorizationURL: u.authorizationURL, + AdditionalAuthcodeParams: u.additionalAuthcodeParams, ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { if u.authcodeExchangeErr != nil { return nil, u.authcodeExchangeErr } - return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}}, nil + return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken}, nil }, PasswordCredentialsGrantAndValidateTokensFunc: func(ctx context.Context, username, password string) (*oidctypes.Token, error) { if u.passwordGrantErr != nil { return nil, u.passwordGrantErr } - return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}}, nil + return &oidctypes.Token{IDToken: &oidctypes.IDToken{Claims: u.idToken}, RefreshToken: u.refreshToken}, nil + }, + PerformRefreshFunc: func(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + if u.performRefreshErr != nil { + return nil, u.performRefreshErr + } + return u.refreshedTokens, nil + }, + ValidateTokenFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { + if u.validateTokenErr != nil { + return nil, u.validateTokenErr + } + return u.validatedTokens, nil }, } } @@ -479,6 +703,7 @@ func RequireAuthCodeRegexpMatch( wantDownstreamNonce string, wantDownstreamClientID string, wantDownstreamRedirectURI string, + wantCustomSessionData *psession.CustomSessionData, ) { t.Helper() @@ -513,6 +738,7 @@ func RequireAuthCodeRegexpMatch( wantDownstreamRequestedScopes, wantDownstreamClientID, wantDownstreamRedirectURI, + wantCustomSessionData, ) // One PKCE should have been stored. @@ -563,7 +789,8 @@ func validateAuthcodeStorage( wantDownstreamRequestedScopes []string, wantDownstreamClientID string, wantDownstreamRedirectURI string, -) (*fosite.Request, *openid.DefaultSession) { + wantCustomSessionData *psession.CustomSessionData, +) (*fosite.Request, *psession.PinnipedSession) { t.Helper() const ( @@ -591,16 +818,16 @@ func validateAuthcodeStorage( testutil.RequireTimeInDelta(t, time.Now(), storedRequestFromAuthcode.RequestedAt, timeComparisonFudgeFactor) // We're not using these fields yet, so confirm that we did not set them (for now). - require.Empty(t, storedSessionFromAuthcode.Subject) - require.Empty(t, storedSessionFromAuthcode.Username) - require.Empty(t, storedSessionFromAuthcode.Headers) + require.Empty(t, storedSessionFromAuthcode.Fosite.Subject) + require.Empty(t, storedSessionFromAuthcode.Fosite.Username) + require.Empty(t, storedSessionFromAuthcode.Fosite.Headers) // The authcode that we are issuing should be good for the length of time that we declare in the fosite config. - testutil.RequireTimeInDelta(t, time.Now().Add(authCodeExpirationSeconds*time.Second), storedSessionFromAuthcode.ExpiresAt[fosite.AuthorizeCode], timeComparisonFudgeFactor) - require.Len(t, storedSessionFromAuthcode.ExpiresAt, 1) + testutil.RequireTimeInDelta(t, time.Now().Add(authCodeExpirationSeconds*time.Second), storedSessionFromAuthcode.Fosite.ExpiresAt[fosite.AuthorizeCode], timeComparisonFudgeFactor) + require.Len(t, storedSessionFromAuthcode.Fosite.ExpiresAt, 1) // Now confirm the ID token claims. - actualClaims := storedSessionFromAuthcode.Claims + actualClaims := storedSessionFromAuthcode.Fosite.Claims // Check the user's identity, which are put into the downstream ID token's subject, username and groups claims. require.Equal(t, wantDownstreamIDTokenSubject, actualClaims.Subject) @@ -634,6 +861,9 @@ func validateAuthcodeStorage( require.Empty(t, actualClaims.AuthenticationContextClassReference) require.Empty(t, actualClaims.AuthenticationMethodsReference) + // Check that the custom Pinniped session data matches. + require.Equal(t, wantCustomSessionData, storedSessionFromAuthcode.Custom) + return storedRequestFromAuthcode, storedSessionFromAuthcode } @@ -642,7 +872,7 @@ func validatePKCEStorage( oauthStore fositestoragei.AllFositeStorage, storeKey string, storedRequestFromAuthcode *fosite.Request, - storedSessionFromAuthcode *openid.DefaultSession, + storedSessionFromAuthcode *psession.PinnipedSession, wantDownstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod string, ) { t.Helper() @@ -667,7 +897,7 @@ func validateIDSessionStorage( oauthStore fositestoragei.AllFositeStorage, storeKey string, storedRequestFromAuthcode *fosite.Request, - storedSessionFromAuthcode *openid.DefaultSession, + storedSessionFromAuthcode *psession.PinnipedSession, wantDownstreamNonce string, ) { t.Helper() @@ -686,13 +916,13 @@ func validateIDSessionStorage( require.Equal(t, wantDownstreamNonce, storedRequestFromIDSession.Form.Get("nonce")) } -func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requester) (*fosite.Request, *openid.DefaultSession) { +func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requester) (*fosite.Request, *psession.PinnipedSession) { t.Helper() storedRequest, ok := storedAuthorizeRequest.(*fosite.Request) require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest, &fosite.Request{}) - storedSession, ok := storedAuthorizeRequest.GetSession().(*openid.DefaultSession) - require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest.GetSession(), &openid.DefaultSession{}) + storedSession, ok := storedAuthorizeRequest.GetSession().(*psession.PinnipedSession) + require.Truef(t, ok, "could not cast %T to %T", storedAuthorizeRequest.GetSession(), &psession.PinnipedSession{}) return storedRequest, storedSession } diff --git a/internal/testutil/psession.go b/internal/testutil/psession.go new file mode 100644 index 00000000..28e65968 --- /dev/null +++ b/internal/testutil/psession.go @@ -0,0 +1,43 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "testing" + + "github.com/ory/fosite/handler/openid" + v1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/fake" + testing2 "k8s.io/client-go/testing" + + "go.pinniped.dev/internal/psession" +) + +func NewFakePinnipedSession() *psession.PinnipedSession { + return &psession.PinnipedSession{ + Fosite: &openid.DefaultSession{ + Claims: nil, + Headers: nil, + ExpiresAt: nil, + Username: "snorlax", + Subject: "panda", + }, + Custom: &psession.CustomSessionData{ + ProviderUID: "fake-provider-uid", + ProviderType: "fake-provider-type", + ProviderName: "fake-provider-name", + OIDC: &psession.OIDCSessionData{ + UpstreamRefreshToken: "fake-upstream-refresh-token", + }, + }, + } +} + +func LogActualJSONFromCreateAction(t *testing.T, client *fake.Clientset, actionIndex int) { + t.Log("actual value of CreateAction secret data", string(client.Actions()[actionIndex].(testing2.CreateActionImpl).Object.(*v1.Secret).Data["pinniped-storage-data"])) +} + +func LogActualJSONFromUpdateAction(t *testing.T, client *fake.Clientset, actionIndex int) { + t.Log("actual value of UpdateAction secret data", string(client.Actions()[actionIndex].(testing2.UpdateActionImpl).Object.(*v1.Secret).Data["pinniped-storage-data"])) +} diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index 1a147b43..1baab58b 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -20,6 +20,7 @@ import ( "github.com/go-ldap/ldap/v3" "github.com/google/uuid" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/utils/trace" @@ -82,6 +83,9 @@ type ProviderConfig struct { // Name is the unique name of this upstream LDAP IDP. Name string + // ResourceUID is the Kubernetes resource UID of this identity provider. + ResourceUID types.UID + // Host is the hostname or "hostname:port" of the LDAP server. When the port is not specified, // the default LDAP port will be used. Host string @@ -265,6 +269,10 @@ func (p *Provider) GetName() string { return p.c.Name } +func (p *Provider) GetResourceUID() types.UID { + return p.c.ResourceUID +} + // Return a URL which uniquely identifies this LDAP provider, e.g. "ldaps://host.example.com:1234?base=user-search-base". // This URL is not used for connecting to the provider, but rather is used for creating a globally unique user // identifier by being combined with the user's UID, since user UIDs are only unique within one provider. diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index 3a249c1d..7d4006c8 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -14,6 +14,7 @@ import ( coreosoidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "go.pinniped.dev/internal/httputil/httperr" @@ -31,19 +32,31 @@ func New(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Clie // ProviderConfig holds the active configuration of an upstream OIDC provider. type ProviderConfig struct { - Name string - UsernameClaim string - GroupsClaim string - Config *oauth2.Config - Client *http.Client - AllowPasswordGrant bool - Provider interface { + Name string + ResourceUID types.UID + UsernameClaim string + GroupsClaim string + Config *oauth2.Config + Client *http.Client + AllowPasswordGrant bool + AdditionalAuthcodeParams map[string]string + Provider interface { Verifier(*coreosoidc.Config) *coreosoidc.IDTokenVerifier Claims(v interface{}) error UserInfo(ctx context.Context, tokenSource oauth2.TokenSource) (*coreosoidc.UserInfo, error) } } +var _ provider.UpstreamOIDCIdentityProviderI = (*ProviderConfig)(nil) + +func (p *ProviderConfig) GetResourceUID() types.UID { + return p.ResourceUID +} + +func (p *ProviderConfig) GetAdditionalAuthcodeParams() map[string]string { + return p.AdditionalAuthcodeParams +} + func (p *ProviderConfig) GetName() string { return p.Name } @@ -109,6 +122,16 @@ func (p *ProviderConfig) ExchangeAuthcodeAndValidateTokens(ctx context.Context, return p.ValidateToken(ctx, tok, expectedIDTokenNonce) } +func (p *ProviderConfig) PerformRefresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + // Use the provided HTTP client to benefit from its CA, proxy, and other settings. + httpClientContext := coreosoidc.ClientContext(ctx, p.Client) + // Create a TokenSource without an access token, so it thinks that a refresh is immediately required. + // Then ask it for the tokens to cause it to perform the refresh and return the results. + return p.Config.TokenSource(httpClientContext, &oauth2.Token{RefreshToken: refreshToken}).Token() +} + +// ValidateToken will validate the ID token. It will also merge the claims from the userinfo endpoint response, +// if the provider offers the userinfo endpoint. func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { idTok, hasIDTok := tok.Extra("id_token").(string) if !hasIDTok { @@ -135,7 +158,7 @@ func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, e } maybeLogClaims("claims from ID token", p.Name, validatedClaims) - if err := p.fetchUserInfo(ctx, tok, validatedClaims); err != nil { + if err := p.maybeFetchUserInfoAndMergeClaims(ctx, tok, validatedClaims); err != nil { return nil, httperr.Wrap(http.StatusInternalServerError, "could not fetch user info claims", err) } @@ -156,7 +179,7 @@ func (p *ProviderConfig) ValidateToken(ctx context.Context, tok *oauth2.Token, e }, nil } -func (p *ProviderConfig) fetchUserInfo(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}) error { +func (p *ProviderConfig) maybeFetchUserInfoAndMergeClaims(ctx context.Context, tok *oauth2.Token, claims map[string]interface{}) error { idTokenSubject, _ := claims[oidc.IDTokenSubjectClaim].(string) if len(idTokenSubject) == 0 { return nil // defer to existing ID token validation diff --git a/internal/upstreamoidc/upstreamoidc_test.go b/internal/upstreamoidc/upstreamoidc_test.go index 2ffd40ca..342683fc 100644 --- a/internal/upstreamoidc/upstreamoidc_test.go +++ b/internal/upstreamoidc/upstreamoidc_test.go @@ -23,6 +23,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.pinniped.dev/internal/mocks/mockkeyset" + "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" ) @@ -288,6 +289,171 @@ func TestProviderConfig(t *testing.T) { } }) + t.Run("PerformRefresh", func(t *testing.T) { + tests := []struct { + name string + returnIDTok string + returnAccessTok string + returnRefreshTok string + returnTokType string + returnExpiresIn string + tokenStatusCode int + + wantErr string + wantToken *oauth2.Token + wantTokenExtras map[string]interface{} + }{ + { + name: "success when the server returns all tokens in the refresh result", + returnIDTok: "test-id-token", + returnAccessTok: "test-access-token", + returnRefreshTok: "test-refresh-token", + returnTokType: "test-token-type", + returnExpiresIn: "42", + tokenStatusCode: http.StatusOK, + wantToken: &oauth2.Token{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + TokenType: "test-token-type", + Expiry: time.Now().Add(42 * time.Second), + }, + wantTokenExtras: map[string]interface{}{ + // the ID token only appears in the extras map + "id_token": "test-id-token", + // the library also repeats all the other keys/values returned by the server in the raw extras map + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "token_type": "test-token-type", + "expires_in": "42", + // the library also adds this zero-value even though the server did not return it + "expiry": "0001-01-01T00:00:00Z", + }, + }, + { + name: "success when the server does not return a new refresh token in the refresh result", + returnIDTok: "test-id-token", + returnAccessTok: "test-access-token", + returnRefreshTok: "", + returnTokType: "test-token-type", + returnExpiresIn: "42", + tokenStatusCode: http.StatusOK, + wantToken: &oauth2.Token{ + AccessToken: "test-access-token", + // the library sets the original refresh token into the result, even though the server did not return that + RefreshToken: "test-initial-refresh-token", + TokenType: "test-token-type", + Expiry: time.Now().Add(42 * time.Second), + }, + wantTokenExtras: map[string]interface{}{ + // the ID token only appears in the extras map + "id_token": "test-id-token", + // the library also repeats all the other keys/values returned by the server in the raw extras map + "access_token": "test-access-token", + "token_type": "test-token-type", + "expires_in": "42", + // the library also adds this zero-value even though the server did not return it + "expiry": "0001-01-01T00:00:00Z", + }, + }, + { + name: "success when the server does not return a new ID token in the refresh result", + returnIDTok: "", + returnAccessTok: "test-access-token", + returnRefreshTok: "test-refresh-token", + returnTokType: "test-token-type", + returnExpiresIn: "42", + tokenStatusCode: http.StatusOK, + wantToken: &oauth2.Token{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + TokenType: "test-token-type", + Expiry: time.Now().Add(42 * time.Second), + }, + wantTokenExtras: map[string]interface{}{ + // the library also repeats all the other keys/values returned by the server in the raw extras map + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "token_type": "test-token-type", + "expires_in": "42", + // the library also adds this zero-value even though the server did not return it + "expiry": "0001-01-01T00:00:00Z", + }, + }, + { + name: "server returns an error on token refresh", + tokenStatusCode: http.StatusForbidden, + wantErr: "oauth2: cannot fetch token: 403 Forbidden\nResponse: fake error\n", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + require.Equal(t, 4, len(r.Form)) + require.Equal(t, "test-client-id", r.Form.Get("client_id")) + require.Equal(t, "test-client-secret", r.Form.Get("client_secret")) + require.Equal(t, "refresh_token", r.Form.Get("grant_type")) + require.Equal(t, "test-initial-refresh-token", r.Form.Get("refresh_token")) + if tt.tokenStatusCode != http.StatusOK { + http.Error(w, "fake error", tt.tokenStatusCode) + return + } + var response struct { + oauth2.Token + IDToken string `json:"id_token,omitempty"` + ExpiresIn string `json:"expires_in,omitempty"` + } + response.IDToken = tt.returnIDTok + response.AccessToken = tt.returnAccessTok + response.RefreshToken = tt.returnRefreshTok + response.TokenType = tt.returnTokType + response.ExpiresIn = tt.returnExpiresIn + w.Header().Set("content-type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(&response)) + })) + t.Cleanup(tokenServer.Close) + + p := ProviderConfig{ + Name: "test-name", + UsernameClaim: "test-username-claim", + GroupsClaim: "test-groups-claim", + Config: &oauth2.Config{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://example.com", + TokenURL: tokenServer.URL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"scope1", "scope2"}, + }, + } + + tok, err := p.PerformRefresh( + context.Background(), + "test-initial-refresh-token", + ) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Nil(t, tok) + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantToken.TokenType, tok.TokenType) + require.Equal(t, tt.wantToken.RefreshToken, tok.RefreshToken) + require.Equal(t, tt.wantToken.AccessToken, tok.AccessToken) + testutil.RequireTimeInDelta(t, tt.wantToken.Expiry, tok.Expiry, 5*time.Second) + for k, v := range tt.wantTokenExtras { + require.Equal(t, v, tok.Extra(k)) + } + }) + } + }) + t.Run("ExchangeAuthcodeAndValidateTokens", func(t *testing.T) { tests := []struct { name string diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 044417ec..b01e5238 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -808,17 +808,18 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty func (h *handlerState) handleRefresh(ctx context.Context, refreshToken *oidctypes.RefreshToken) (*oidctypes.Token, error) { h.logger.V(debugLogLevel).Info("Pinniped: Refreshing cached token.") - refreshSource := h.oauth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken.Token}) + upstreamOIDCIdentityProvider := h.getProvider(h.oauth2Config, h.provider, h.httpClient) - refreshed, err := refreshSource.Token() + refreshed, err := upstreamOIDCIdentityProvider.PerformRefresh(ctx, refreshToken.Token) if err != nil { // Ignore errors during refresh, but return nil which will trigger the full login flow. + h.logger.V(debugLogLevel).Info("Pinniped: Refresh failed.", "error", err.Error()) return nil, nil } // The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at least // some providers do not include one, so we skip the nonce validation here (but not other validations). - return h.getProvider(h.oauth2Config, h.provider, h.httpClient).ValidateToken(ctx, refreshed, "") + return upstreamOIDCIdentityProvider.ValidateToken(ctx, refreshed, "") } func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Request) (err error) { diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index c63173df..cdb497e6 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -35,6 +35,7 @@ import ( "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/testlogger" + "go.pinniped.dev/internal/upstreamoidc" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" @@ -404,11 +405,17 @@ func TestLogin(t *testing.T) { // nolint:gocyclo clientID: "test-client-id", opt: func(t *testing.T) Option { return func(h *handlerState) error { - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). Return(&testToken, nil) + mock.EXPECT(). + PerformRefresh(gomock.Any(), testToken.RefreshToken.Token). + DoAndReturn(func(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + // Call the real production code to perform a refresh. + return upstreamoidc.New(config, provider, client).PerformRefresh(ctx, refreshToken) + }) return mock } @@ -445,11 +452,17 @@ func TestLogin(t *testing.T) { // nolint:gocyclo clientID: "test-client-id", opt: func(t *testing.T) Option { return func(h *handlerState) error { - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). Return(nil, fmt.Errorf("some validation error")) + mock.EXPECT(). + PerformRefresh(gomock.Any(), "test-refresh-token-returning-invalid-id-token"). + DoAndReturn(func(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + // Call the real production code to perform a refresh. + return upstreamoidc.New(config, provider, client).PerformRefresh(ctx, refreshToken) + }) return mock } @@ -500,6 +513,7 @@ func TestLogin(t *testing.T) { // nolint:gocyclo wantLogs: []string{ `"level"=4 "msg"="Pinniped: Performing OIDC discovery" "issuer"="` + successServer.URL + `"`, `"level"=4 "msg"="Pinniped: Refreshing cached token."`, + `"level"=4 "msg"="Pinniped: Refresh failed." "error"="oauth2: cannot fetch token: 400 Bad Request\nResponse: expected client_id 'test-client-id'\n"`, `"msg"="could not open callback listener" "error"="some listen error"`, }, // Expect this to fall through to the authorization code flow, so it fails here. @@ -1522,11 +1536,17 @@ func TestLogin(t *testing.T) { // nolint:gocyclo }) h.cache = cache - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateToken(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce("")). Return(&testToken, nil) + mock.EXPECT(). + PerformRefresh(gomock.Any(), testToken.RefreshToken.Token). + DoAndReturn(func(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + // Call the real production code to perform a refresh. + return upstreamoidc.New(config, provider, client).PerformRefresh(ctx, refreshToken) + }) return mock } diff --git a/site/content/docs/howto/configure-supervisor-with-dex.md b/site/content/docs/howto/configure-supervisor-with-dex.md index db44d20e..79a5ea35 100644 --- a/site/content/docs/howto/configure-supervisor-with-dex.md +++ b/site/content/docs/howto/configure-supervisor-with-dex.md @@ -85,11 +85,19 @@ spec: # Specify the upstream issuer URL (no trailing slash). issuer: https:// - # Request any scopes other than "openid" for claims besides - # the default claims in your token. The "openid" scope is always - # included. + # Specify how to form authorization requests to Dex. authorizationConfig: - additionalScopes: [groups, email] + + # Request any scopes other than "openid" for claims besides + # the default claims in your token. The "openid" scope is always + # included. + additionalScopes: [offline_access, groups, email] + + # If you would also like to allow your end users to authenticate using + # a password grant, then change this to true. + # Password grants with Dex will only work in Dex versions that include + # this bug fix: https://github.com/dexidp/dex/pull/2234 + allowPasswordGrant: false # Specify how Dex claims are mapped to Kubernetes identities. claims: diff --git a/site/content/docs/howto/configure-supervisor-with-gitlab.md b/site/content/docs/howto/configure-supervisor-with-gitlab.md index fffb0a52..c7998470 100644 --- a/site/content/docs/howto/configure-supervisor-with-gitlab.md +++ b/site/content/docs/howto/configure-supervisor-with-gitlab.md @@ -30,8 +30,13 @@ For example, to create a user-owned application: 1. Create a new application: 1. Enter a name for your application, such as "My Kubernetes Clusters". 1. Enter the redirect URI. This is the `spec.issuer` you configured in your `FederationDomain` appended with `/callback`. - 1. Check the box saying that the application is _Confidential_. - 1. Select scope `openid`. This provides access to the `nickname` (GitLab username) and `groups` (GitLab groups) claims. + 1. Check the box saying that the application is _Confidential_. This is required and will cause GitLab to autogenerate + a client ID and client secret for your application. + 1. Check the box saying to _Expire Access Tokens_ to cause refresh tokens to be returned to the Supervisor. + 1. Select scope `openid`. This is required to get ID tokens. Also, this provides access to the `nickname` (GitLab username) + and `groups` (GitLab groups) claims in the ID tokens. + 1. Optionally select other scopes which might provide access to other claims that you might want to use to determine + the usernames of your users, for example `email`. 1. Save the application and make note of the _Application ID_ and _Secret_. ## Configure the Supervisor cluster @@ -51,6 +56,23 @@ spec: # Specify the upstream issuer URL. issuer: https://gitlab.com + # Specify how to form authorization requests to GitLab. + authorizationConfig: + + # GitLab is unusual among OIDC providers in that it returns an + # error if you request the "offline_access" scope during an + # authorization flow, so ask Pinniped to avoid requesting that + # scope when using GitLab by excluding it from this list. + # By specifying only "openid" here then Pinniped will only + # request "openid". + additionalScopes: [openid] + + # If you would also like to allow your end users to authenticate using + # a password grant, then change this to true. See + # https://docs.gitlab.com/ee/api/oauth2.html#resource-owner-password-credentials-flow + # for more information about using the password grant with GitLab. + allowPasswordGrant: false + # Specify how GitLab claims are mapped to Kubernetes identities. claims: diff --git a/site/content/docs/howto/configure-supervisor-with-okta.md b/site/content/docs/howto/configure-supervisor-with-okta.md index 6c01fb72..d90c7d82 100644 --- a/site/content/docs/howto/configure-supervisor-with-okta.md +++ b/site/content/docs/howto/configure-supervisor-with-okta.md @@ -32,11 +32,18 @@ For example, to create an app: 1. Create a new app: 1. Click `Create App Integration`. 1. For `Sign-on method`, select `OIDC`. - 1. For `Application type`, app `Web Application`, then click next. + 1. For `Application type`, app `Web Application`, then click next. Only if you would like to offer the + password grant flow to your end users, then choose `Native Application` instead. 1. Enter a name for your app, such as "My Kubernetes Clusters". + 1. If you chose to create a `Web Application` then in the General Settings section, choose Grant Types + `Authorization Code` and `Refresh Token`. + 1. If you chose `Native Application` then in the General Settings section, choose Grant Types `Authorization Code`, + `Refresh Token`, and `Resource Owner Password`. 1. Enter the sign-in redirect URI. This is the `spec.issuer` you configured in your `FederationDomain` appended with `/callback`. 1. Optionally select `Limit access to selected groups` to restrict which Okta users can log in to Kubernetes using this integration. - 1. Save the app and make note of the _Client ID_ and _Client secret_. + 1. Save the app and make note of the _Client ID_ and _Client secret_. If you chose to create a `Native Application` + then there is an extra step required to get a client secret: after saving the app, in the + Client Credentials section click `Edit`, choose `Use Client Authentication`, and click `Save`. 1. Navigate to the _Sign On_ tab > _OpenID Connect ID Token_ and click `Edit`. Fill in the Groups claim filter. For example, for all groups to be present under the claim name `groups`, fill in "groups" in the first box, then select "Matches regex" and ".*". @@ -54,17 +61,25 @@ metadata: name: okta spec: - # Specify the upstream issuer URL (no trailing slash). + # Specify the upstream issuer URL (no trailing slash). Change this to be the + # actual issuer provided by your Okta account. issuer: https://my-company.okta.com - # Request any scopes other than "openid" for claims besides - # the default claims in your token. The "openid" scope is always - # included. - # - # To learn more about how to customize the claims returned, see here: - # https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/overview/ + # Specify how to form authorization requests to Okta. authorizationConfig: - additionalScopes: [groups, email] + + # Request any scopes other than "openid" for claims besides + # the default claims in your token. The "openid" scope is always + # included. + # + # To learn more about how to customize the claims returned, see here: + # https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/overview/ + additionalScopes: [offline_access, groups, email] + + # If you would also like to allow your end users to authenticate using + # a password grant, then change this to true. Password grants only work + # with applications created in Okta as "Native Applications". + allowPasswordGrant: false # Specify how Okta claims are mapped to Kubernetes identities. claims: diff --git a/test/integration/formposthtml_test.go b/test/integration/formposthtml_test.go index bfc900df..d845a60c 100644 --- a/test/integration/formposthtml_test.go +++ b/test/integration/formposthtml_test.go @@ -105,6 +105,7 @@ func TestFormPostHTML_Parallel(t *testing.T) { // // The test server supports special `?fail=close` and `?fail=500` to force error cases. func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) { + t.Helper() results := make(chan url.Values) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -155,6 +156,7 @@ func formpostCallbackServer(t *testing.T) (string, func(*testing.T, url.Values)) // formpostTemplateServer runs a test server that serves formposthtml.Template() rendered with test parameters. func formpostTemplateServer(t *testing.T, redirectURI string, responseParams url.Values) string { + t.Helper() handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fosite.WriteAuthorizeFormPostResponse(redirectURI, responseParams, formposthtml.Template(), w) }) @@ -168,6 +170,7 @@ func formpostTemplateServer(t *testing.T, redirectURI string, responseParams url // formpostRandomParams is a helper to generate random OAuth2 response parameters for testing. func formpostRandomParams(t *testing.T) url.Values { + t.Helper() generator := &hmac.HMACStrategy{GlobalSecret: testlib.RandBytes(t, 32), TokenEntropy: 32} authCode, _, err := generator.Generate() require.NoError(t, err) @@ -180,6 +183,7 @@ func formpostRandomParams(t *testing.T) url.Values { // formpostExpectTitle asserts that the page has the expected title. func formpostExpectTitle(t *testing.T, page *agouti.Page, expected string) { + t.Helper() actual, err := page.Title() require.NoError(t, err) require.Equal(t, expected, actual) @@ -187,6 +191,7 @@ func formpostExpectTitle(t *testing.T, page *agouti.Page, expected string) { // formpostExpectTitle asserts that the page has the expected SVG/emoji favicon. func formpostExpectFavicon(t *testing.T, page *agouti.Page, expected string) { + t.Helper() iconURL, err := page.First("#favicon").Attribute("href") require.NoError(t, err) require.True(t, strings.HasPrefix(iconURL, "data:image/svg+xml,