--- tags: api-design --- # Pinniped Supervisor `LDAPIdentityProvider` Support ## Goal We would like to come up with some user stories for Pinniped Supervisor LDAP upstream IDP support, reasonably sequenced for iterative development. ## Design Considerations Open Questions: - What does our LDAP config look like? (See below for detailed notes.) - `LDAPIdentityProvider` CRD with: - Server config: we may want fallback servers in the future but just one for now (TLS, endpoint, bind username/password secret name) - Autodetect TLS vs StartTLS at LDAPIdentityProvider creation time. - How do we find your user record and how do we map identity? - What is the user experience of logging in via Pinniped? Is it entering your username/password on the CLI or in a Supervisor web page, or either/both? - Do we need a way to uniquely identify the LDAP IDP so when we (later) support multiple upstream IDPs we can tell the difference between users with the same username which came from different IDPs (e.g. two different LDAPs, or an LDAP user vs. an OIDC IDP user)? - Could we use the LDAP URL or LDAPIdentityProvider's name to help disambiguate, or should there be another configuration option? - What username would we send to the workload cluster when there are multiple IDPs? - How might it integrate with Kube distros like TKG, especially what might the configuration UX look like? - Can we only support "Search and Bind" style login, which means that the user would need to configure an LDAP service account for us? - We're leaning towards fetching group membership at login (and maybe refresh) time. - However, it is worth noting that there is another option. OpenShift (and maybe Rancher) allows configuration of a background process which would continuously sync all of your LDAP groups into a local cache. This has the advantage of making login/refresh a little bit faster, and has the typical downsides of caching. - How would we implement downstream OIDC refresh? Some ideas: - Checking your user account again during refresh - Perhaps we encrypt their ldap password and save it into their refresh token. Then when they hand back the token for refresh we decrypt it and perform the search and bind again just like we did during their initial login. - Or perhaps we allow the configuration of a field name that we can use to know that the user's record has been disabled. Then during refresh we perform the search (without the bind) and check that field of the user record. If the search fails or the search results in a disabled user, then the refresh fails. - Checking your group membership again during refresh - Perform the same group search that we performed during the initial login, and update your group membership in the new downstream tokens and corresponding backend session - Maybe revoke your token if your group membership is less now? Things that need to be configured to be able to test your username/password combination and get your groups: 1. How to connect to the LDAP server - A URL pointing to the LDAP server, must start with `ldap://` or `ldaps://` - The username and password of your read-only service account - Do we need to support plaintext LDAP (i.e. `ldap://` without `StartTLS`)? Can we only support `ldaps://`, or do we also need to support `ldap://` with `StartTLS`? - Allow configuring a trusted CA cert for TLS per LDAP IDP - Allow MTLS client certs (Dex allows it)? Might be less important. 2. How to search for the user record in the LDAP tree - Search base: specify only if a part of the directory should be searched, for example `dc=test,dc=com` - The name of the LDAP record attribute which we should treat as your username? Or copy UAA and instead allow arbitrary user search filters, e.g. `'cn={0}'` where `{0}` would be replaced by the username provided by the user? (In the later case, we don't need to explicitly know which attribute name the username came from, because we know the username that we searched for and we know that the search returned exactly 1 result, so the username must be the username for which we searched. Or both: allow fancy searches but then also allow us to get the case-sensitive username from the found LDAP record. - The name of the attribute from the LDAP record which we should use as the unique (within this provider) user ID, so we can use it as the downstream `sub` claim (along with the URL of the ldap server)? user configurable, with a default value of dn or username - Should we offer a way to make the downstream username a combination of the ldap URL with the LDAP username to make it globally unique? 3. How to find the user's group membership - Search base for the group search - Do we want to support search filters similar to user search filters? e.g `member={0}` or `memberOf={0}` - Do we want to support an option for how many levels deep do we search for nested groups, including a value for this setting which disables nested groups? (UAA defaults this to "10".) - The name of the attribute in the LDAP record which should be used as the group name in the downstream token - When performing the group search, do we need to offer a configuration option to allow the user to specify the attribute name of the user's LDAP record whose value should be used in the group search filter? Somehow UAA avoided having this configuration option, but not sure what value it used in the group search, maybe the fully distinguished name of the user record? - How would we make the group names globally unique? Can we somehow combine them with the IDP name? 4. Other - How should we handle case-(in)sensitivity of usernames, especially when it is the user's email address? Kube usernames are case-sensitive for RBAC. Maybe it should be an option to automatically downcase usernames and group names? - Do we want configuration options for user and group search to search one level vs. all levels? Seems like you would always want to search all levels. - Do we want an off/on configuration option for should the LDAP client instruct the server to follow referrals? Seems like you would always want to follow referrals. - UAA had a configuration option related to group search called `ldap.groups.ignorePartialResultException` which is documented [here](https://github.com/cloudfoundry/uaa/blob/develop/docs/UAA-LDAP.md#configuration-references). Do we care about partial results? What is a partial result anyways? Other systems that we looked at don't offer similar options. - Do we want to add configuration options to the `FederationDomain` and/or the `LDAPIdentityProvider` which would allow the user to filter/transform/disallow the upstream group names/memberships, e.g. to prevent some groups from being reflected downstream? - Since we're going to prompt users for their username, it might be nice to allow the configuration of the label of the prompt, e.g. "Username" or "Email" or "AD account name" or whatever. ## Candidate User Stories The following are in no particular order, except that the first story would come first. After prioritzing the list, we could draw a line and call everything above that line the MVP. Perhaps we decide that we never do some of these stories (e.g. mTLS). 1. Basic user login with basic LDAP connection. Connect with `ldaps://`, without any custom CA, authenticate using the provided service account credentials, and perform the user search and bind. No group search. Only support CLI-based username/password prompt. Only support those fields of LDAPIdentityProvider which are required for this basic first story. Other stories in no particular order: 1. Add the other most common LDAP connection options. Add support for StartTLS and custom CA. No MTLS. No insecure/plaintext LDAP. 1. Web-based username/password prompt with the web page hosted by the Supervisor. 1. Perform the most basic version of group search during initial login. 1. Perform client-side nested group search. 1. During downstream token refresh, perform some kind of upstream authentication again. 1. During downstream token refresh, perform group search again. 1. Support customizing the username prompt. 1. Support customizing the web-based login page. 1. Allow multiple upstream IDPs. 1. Support custom filters and transformations on upstream->downstream usernames and group names. 1. Suport an AD-specific ActiveDirectoryIdentityProvider to make configuration of AD easier, and maybe to support server-side nested group search. 1. Allow insecure/plaintext conenctions to the LDAP server. 1. Support mTLS certs for connecting to the LDAP server. 1. CLI support in the `pinniped get kubeconfig` command. ## CLI Login and Supervisor Endpoints for LDAP Login ### Design Goals - The CLI should prompt the user for their username and password, and should not be required to open a web browser. - Use a standards-based flow which supports identity/authn. - The STS token exchange endpoint exchanges downstream access tokens for cluster-specific ID tokens. This is only possible if the backend fosite storage includes an OIDC session for the access token. This rules out the OAuth password grant flow, since that is not an OIDC flow. The password grant flow is supported by fosite, but it results in a backend session which has no user identity or group membership included (OAuth is authz only). It would be possible to hack on fosite to implement a custom grant type which is very similar to the password grant, but that seems unnessesary and would not be based on a published standard. - Secondary goal: Leave room for web-based login support. - It would be nice if other clients aside from our CLI could use a web-based authcode flow, like they do today. This would allow 3rd party clients like the Kubeapps Pinniped client to remain unchanged, and would also avoid having the user's LDAP password ever be seen by those clients. We do not need to support this in the first draft, although we can leave room for it in the design. ### Proposed Solution (Work in Progress 👷‍️🚧👷‍♀️) #### Supervisor `FederationDomain` Upstream IDP Discovery The Supervisor's `FederationDomain`'s OIDC discovery endpoint responses could be enhanced to return a new `pinniped_upstream_identity_providers` field. This field would advertise a list of upstream IDPs that are currently configured to work with this `FederationDomain`. For example: > [name=Mo Khan] Regarding the above, I don't understand why we are trying to hack on a custom API on top of OIDC discovery. We should create a distinct API that does exactly what we want, is versioned like the rest of our APIs, can accept a .spec to change its output - i.e. hide admin IDPs, etc. ```json { "issuer": "https://supervisor.mycompany.com/issuer1", "authorization_endpoint": "https://supervisor.mycompany.com/issuer1/oauth2/authorize", "token_endpoint": "https://supervisor.mycompany.com/issuer1/oauth2/token", "jwks_uri": "https://supervisor.mycompany.com/issuer1/jwks.json", ... "pinniped_upstream_identity_providers": [ { "name": "corporate-active-directory", "type": "ldap", "username_prompt": "Username (corporate email address)", "password_prompt": "Password" }, { "name": "jumpcloud", "type": "ldap", "username_prompt": "Jumpcloud Username", "password_prompt": "Gimme your secret 🔐" }, { "name": "okta", "type": "oidc" } ] } ``` > [name=Mo Khan] I would like to see some type of strategies in the IDP discovery doc, i.e. "go to authorize URL ..." or "send your password here" similar to how the credential issuer works. #### Enhancements to `pinniped get kubeconfig` The behavior of `pinniped get kubeconfig` will need to be enhanced to support LDAP upstream IDPs. Also note that this design is closely related to how we might support multiple upstream identity providers in the near future. First, let's recall how `pinniped get kubeconfig` works. If run against a workload cluster whose Concierge is configured to using a Supervisor, then `pinniped get kubeconfig` just sees a `JWTAuthenticator` on the cluster with an issuer URL. It does not interact with the Supervisor today, so it does not see which identity providers are configured on the Supervisor. It doesn't even know if that issuer URL represents a Supervisor or some other kind of OIDC Provider. Upstream LDAP is only relevant when `pinniped get kubeconfig` chooses a `JWTAuthenticator` either by auto-selection or when the user specifically selected one via the existing command-line flags. Furthermore, upstream LDAP is only relevant when that `JWTAuthenticator`’s issuer is a Supervisor. When `pinniped get kubeconfig` chooses a `JWTAuthenticator` it could: - By default, call the issuer's OIDC discovery endpoint to find out if the Provider is a Supervisor which is advertising its supported upstream IDPs. - If there is a custom `pinniped_upstream_identity_providers` section: - If there is exactly 1 IDP listed, add new `--upstream-idp-name <name> --upstream-idp-type <type>` flags to the `pinniped login oidc` command in the resulting kubeconfig for that IDP. - If there is 0 or more than 1 IDP (for forwards-compatibility) then return an error. In the error message, list the IDP names and types so the user can run the command again and specify which IDP they want. - If there is *not* is a custom `pinniped_upstream_identity_providers` section: - This issuer is not a Supervisor, or is a Supervisor which does not want to advertise any upsteam IDPs (maybe that's a feature someday?), or is an old Supervisor from before this feature was added. Do not add the new flags to the `pinniped login oidc` command in the resulting kubeconfig. - On the other hand, if the user used new flags `pinniped get kubeconfig --upstream-idp-name <name> --upstream-idp-type <type>` to ask to choose a specific upstream IDP then do similar OIDC discovery as described above, except: - If there is a custom `pinniped_upstream_identity_providers` section: - There must exist an IDP with the matching name and type, or else return error - If there is NOT is a custom `pinniped_upstream_identity_providers` section: - Then return an error - If only one of `--upstream-idp-name` or `--upstream-idp-type` is specified, then return an error if it matches zero or more than one upstream IDP. A potential future feature, not needed at this time, would be for `pinniped get kubeconfig` to list all available upstream IDPs when one was not specified by the CLI options and to interactively prompt the user to choose one. Today we do not support interactive prompts to select login options. #### Enhancements to `pinniped login oidc` Using the kubeconfig generated by the above logic, when the end-user runs `kubectl`, our exec plugin CLI is executed. The CLI may or may not have been told which upstream IDP to use through the new `pinniped login oidc --upstream-idp-name <name> --upstream-idp-type <type>` flags. When either flag is specified, then they both must be specified. - When the new flags are specified: - The CLI always performs discovery using the issuer URL. - If it can successfully confirm that the `--upstream-idp-name` and `--upstream-idp-type` flags refer to an upstream IDP that actually exists and is of the expected type, then use that name/type for login. If that IDP is of type LDAP, then note the values of the custom username and password prompts for use during login. - Otherwise, try to go ahead and log in anyway using the specified `--upstream-idp-name` and `--upstream-idp-type` values. For LDAP IDPs, use default values for the username and password prompts. If the login fails, print a special warning or error indicating that the user was trying to log in to an IDP that was not listed during discovery, to account for when a user has manually specified this flag and typoed the input or when an IDP that used to be discoverable becomes non-discoverable. - When the new flags are *not* specified: - Perform OIDC discovery as described above. - If the response does *not* include `pinniped_upstream_identity_providers`, then proceed as usual and allow the login to continue. This is probably not a Supervisor. - Otherwise, there must be exactly one upstream IDP listed, and it must be of type OIDC. If so, use it. Otherwise, return an error which says that the new flags are required because there are multiple IDPs. This error would only be seen by users who generated the kubeconfig with an old version of pinniped from before this feature was added, because `pinniped get kubeconfig` should have put these flags on the command already. - This provides backwards compatibility and supports the use case where the CLI is being used with a non-Supervisor OIDC provider (or an old or secretive Supervisor). Now that `pinniped login oidc` has found the upstream IDP discovery information, and it knows which upstream IDP to use, it can proceed to start the OIDC authorization flow. For OIDC IDPs it can proceed as it always has previously. For LDAP IDPs it can prompt the user for a username and password using the custom prompt text from the discovery endpoint. Rather than opening a web browser, it can directly perform a GET (or POST?) to the authorize endpoint. This request should include new custom headers (or form params in the body of a POST?) which specify the username and password. The IDP name and IDP type should be specified as new query params. The authorization endpoint will confirm this combination of inputs and, if successful for an LDAP IDP login, will return a redirect to the CLI's localhost listener with the authcode. The authorization endpoint will ignore the username and password if they were accidentally sent for an upstream OIDC IDP. Because `LDAPIdentityProvider` and `OIDCIdentitiyProvider` are different custom resources, instances of those resources can have the same names. By sending both the name and type of the upstream IDP as request parameters, potential name collisions are resolved. #### Possible Future Feature: Obscuring of Upstream IDPs from Discovery A potential future feature would be to allow the Supervisor administrator to configure a `FederationDomain`'s upstream IDPs in such a way that some IDPs are not advertised via the `FederationDomain`'s discovery endpoint. This would only be for user convienience, not for security purposes. Users could still try to log in using that IDP, and it would be up to the IDP itself to prevent unwanted users from logging in. This would allow a Supervisor administrator to define IDPs that are meant for a special subset of users, and not for the general population of workload cluster administrators, without confusing the general population of workload cluster administrators by showing it as an option during `pinniped get kubeconfig`. As long as `pinniped login oidc` does not list available upstream IDPs, then this feature would not be needed to obscure special IDPs from the general population of cluster end users. Unless they went out of their way to directly curl the Supervisor discovery endpoint on their own, they won't be presented with the list as part of their user experience. A small change to the `pinniped get kubeconfig` command would be needed to implement this feature. A new command-line option would allow the user to skip discovery and force the `--upstream-idp-name` and `--upstream-idp-type` values to be used without checking if that upstream IDP is listed by the discovery endpoint. Since `pinniped login oidc` will go ahead and use whatever `--upstream-idp-name` and `--upstream-idp-type` values were passed to it, it would not need to change beyond what was already described above. #### Possible Future Feature: Web-Based LDAP Login Support This design leaves room for potentially supporting web-based LDAP login in the future. Any client can perform the same discovery discussed above. Any client can then start an authorization flow using the additional new IDP name and type params to the GET authorization request. The params could be optional for the case where there is only one upstream IDP defined on the `FederationDomain`. Here is how that might work: 1. The Supervisor receives the authorization request. 1. If the request includes the user's username and password via the custom headers, reject the request. Only the CLI OAuth client should be allowed to use that method of authentication, because only the CLI client with the localhost redirect URI should be trusted to handle the user's password. 2. If the request does not include the upstream IDP name/type request parameters and there is more than one upstream IDP configured, then redirect to a HTML page which prompts the user to choose which IDP they would like to use. When they choose, redirect back to the authorize endpoint with the request parameters set. 3. It looks up the requested identity provider configuration. It sees that it has selected an `LDAPIdentityProvider`. 4. It encodes the request params into a new state param, just like it does today for an `OIDCIdentityProvider`. 5. Instead of returning a redirect to an upstream identity provider like it would for an `OIDCIdentityProvider`, it simply returns a redirect to another URL on the Supervisor. It encodes the state param into that redirect. The path of the redirect would be something like `<issuer_path>/ldap/<ldap_idp_name>/login`. It also sets a CSRF cookie on the response just like it does today. 3. The client follows the redirect by performing a GET and automatically includes the cookie on the request. 4. The Supervisor receives the GET `<issuer_path>/ldap/<idp_name>/login` request and renders an HTML login form: - The form's submit URL is `<issuer_path>/ldap/<idp_name>/login` again. - The state param's value is written into a hidden form input, properly escaped. - Username and password form inputs are shown with the custom username and password prompts as labels. 5. The Supervisor receives the `POST` login request. It takes the following steps in that endpoint: 1. Decode your state form param to reconstitute the original authorization request params (the client's nonce and PKCE, requested scopes, etc) and also compare the incoming CSRF cookie to the value from the state param. This code would be identical to what we do in the upstream OIDC callback endpoint today. If the decoded state param's timestamp is too old, it might be prudent to reject the request. 1. Bind to the LDAP server and verify your username/password and get your downstream username and groups. 1. For a successful login, mint an authcode and save your OIDC session indexed by that authcode. This code would be identical to what we do in the callback endpoint today. Redirect to the client's redirect URI with the new authcode. 1. For a failed login, redirect back to the login page with an error param so the page can render an error message. Include the state param again. ## References Other similar LDAP clients: - Dex https://dexidp.io/docs/connectors/ldap/ - UAA - Configuration via API http://docs.cloudfoundry.org/api/uaa/version/75.0.0/index.html#ldap - Docs about how LDAP integration works https://github.com/cloudfoundry/uaa/blob/develop/docs/UAA-LDAP.md#ldap-authentication - Rancher https://rancher.com/docs/rancher/v2.x/en/admin-settings/authentication/ - OpenShift - https://github.com/openshift/api/blob/8de5cee96c0543faad13858c05fe0acb58a47e8e/config/v1/types_oauth.go#L326 - https://docs.openshift.com/container-platform/4.6/authentication/ldap-syncing.html Early draft of potential Pinniped config: - https://gist.github.com/mattmoyer/c089ef0cefb24c538cdebe5af7451c45#file-03-idp-config-sample-ldap-yaml Golang LDAP client library: - Library https://github.com/go-ldap/ldap - Library docs https://pkg.go.dev/github.com/go-ldap/ldap - Example of usage, from OpenShift https://github.com/openshift/oauth-server/blob/95a1b0f0cd2a20748f366f44ddadce61b6129805/pkg/authenticator/password/ldappassword/ldap.go Our GitHub issues: - https://github.com/vmware-tanzu/pinniped/issues?q=is%3Aissue+is%3Aopen+ldap ## Implementation design notes (2021-03-24) - ActiveDirectoryIdentityProvider CRD - Lots of hardcoded fields - Some special behavior re:nested groups - Future: some special auto-join behavior using a keytab file - LDAPIdentityProvider CRD - More fields, docs are harder to read - Both CRDs map to some internal type that's a superset of both? - Migration between the types might be hard because: - We don't support multiple IDPs, so you'd have to cut over all at once - A user switching from generic LDAP to AD might have chosen other non-default options that break somehow Sources of implementation complexity: - Adding password-based flows to CLI and supervisor -