Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: third party custom providers #1984

Merged
merged 8 commits into from
Dec 4, 2024
Merged

Conversation

lfleischmann
Copy link
Member

@lfleischmann lfleischmann commented Nov 28, 2024

Description

These changes enable configuration and usage of custom third party OAuth/OIDC providers.

Implementation

  • New configuration option third_party.custom_providers. custom_providers is a map of arbitrarily chosen keys to a CustomThirdPartyProvider - this is implemented as a new type differing from the existing configuration type ThirdPartyProvider used for built-in providers because they have different configuration requirements.

  • Both ThirdPartyProvider and CustomThirdPartyProvider types get a non- configurable, automatically populated Name (during the config's PostProcess) that sort of serves as an identifier/slug for the provider in order to distinguish provider types at runtime.

    • A CustomThirdPartyProviders Name is automatically prefixed during PostProcess with a custom_ prefix to ensure that providers can be distinguished at runtime.
    • A (built-in) ThirdPartyProviders Name is "hard-coded" through the DefaultConfig.
  • Built-in OAuth/OIDC provider implementations are currently instantiated on-demand instead of once at application startup (i.e. unlike SAML providers) - i.e. when a user requests auth/authz with a third party provider, only then a provider is instantiated and created via factory function (thirdparty.GetProvider). Custom providers follow this pattern, hence the factory function had to be adjusted to take into account providers with the aforementioned custom_ prefix (i.e.: if it is a custom_ provider, instantiate a customProvider implementation).

  • The customProvider implementation uses the go-oidc library. Instances of providers of the type this library offers can be instantiated by passing in an issuer URL. Such an instantiation automatically attempts to retrieve an OIDC discovery document from a .well-known endpoint. This also performs an issuer validation. Providers configured to not use OIDC discovery (i.e. use_discovery in the CustomThirdPartyProvider is false or omitted) "directly" instantiate a provider struct and hence do not do this issuer check (it is only done in the constructor function).

  • The customProvider implementation is further based on the assumption that provider user data is only extracted from a userinfo endpoint response, i.e. in case of an OIDC provider, the implementation does not make use of the ID token - no validation is performed on the ID token.

  • The customProvider implementation requires configuring a list of scopes: because the custom providers allow configuring both OAuth as well as OIDC providers, we cannot simply set a default set of scopes, e.g. openid, which is a required claim for OIDC - some providers return errors on unknown claims so setting this would make the third party auth process prone to errors.

  • The customProvider implementation allows for a simple mapping of claims contained in the userinfo response from the provider to "known" standard OIDC conformant claims at the Hanko backend (defined in the thirdparty.Claims struct) through an attribute_mapping configuration option. The mapping is a simple one-to-one mapping, i.e. no complex mapping instructions are possible, e.g. mapping concatenations of multiple claims in the provider data source or similar. Any other non-standard claims returned by the provider are placed in a custom_claims attribute. Except for the user ID (sub), email and email_verified claims the third party functionality currently does not allow accessing this user data but there's a good chance this will change in the future, so I tried to make sure that any info retrieved from the provider is somehow preserved (it is persisted in the data column for an identity btw. and updated on every login with the provider).

    • I also noticed that the thirdparty.Claims were missing the address claim, so I added that for completeness' sake.
  • The changes also fix a bug in the account linking logic whereby third party connections were established by simply assuming that the email retrieved from the provider was verified. So, even if the email address at the provider was not verified (or the provider simply did/does not provide info about the verification status), an account was created and/or linked and the flow API capabilities of automatically triggering a passcode if the backend was configured to require email verification would not take effect. This was a wrong assumption and the verification status is now based on the actual value retrieved from the provider.

  • In case of a triggered passcode, the changes also modify the token exchange action to prevent showing a back button/link, since it does not make sense to go back to anything right after the token exchange - there is nothing to go "back" to.

How to test

  1. Download Keycloak realm file: hanko-realm.json

  2. Start Keycloak (change path to realm file accordingly):

        docker run --name keycloak_hanko -p 8090:8080 \
        -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
        -v ~/Downloads/hanko-realm.json:/opt/keycloak/data/import/hanko-realm.json \
        quay.io/keycloak/keycloak:latest \
        start-dev --import-realm
    

    You can log in to the admin console at http://localhost:8090 - Username: admin, PW: admin. There is one realm
    (hanko) with two clients (hanko, hanko2) configured. To view these, select the hanko realm at the top left, then
    select clients in the left sidebar . Select hanko or hanko2 from the table.

  3. Client hanko returns email, profile claims, a hardcoded custom claim test_claim that should map to the
    custom_claims attribute since it is a non- standard claim (view the data column after signup/signin - more on the.
    latter in the next steps) and a custom username claim (test_username) that you can use to test the attribute
    mapping (e.g. to map it into the standard claim preferred_username). Configure the provider for this client in the
    backend/config/config.yaml:

         third_party:
           allowed_redirect_urls:
             - http://localhost:8888**
           default_redirect_url: http://localhost:8888
           error_redirect_url: http://localhost:8888
           redirect_url: http://localhost:8000/thirdparty/callback
           custom_providers:
             keycloak:
               allow_linking: true
               enabled: true
               secret: 35c3kiNGCyMEzkBqoQGPON7v0ixDBqQG
               client_id: hanko
               display_name: Keycloak Custom
               use_discovery: true
               issuer: http://localhost:8090/realms/hanko
               scopes:
                 - openid
                 - email
                 - profile
              attribute_mapping:
                preferred_username: test_username
    

    Alternatively, do not use discovery and configure the endpoints explicitly:

         third_party:
           allowed_redirect_urls:
             - http://localhost:8888**
           default_redirect_url: http://localhost:8888
           error_redirect_url: http://localhost:8888
           redirect_url: http://localhost:8000/thirdparty/callback
           custom_providers:
             keycloak:
               allow_linking: true
               enabled: true
               secret: 35c3kiNGCyMEzkBqoQGPON7v0ixDBqQG
               client_id: hanko
               display_name: Keycloak Custom
               authorization_endpoint: http://localhost:8090/realms/hanko/protocol/openid-connect/auth
               token_endpoint: http://localhost:8090/realms/hanko/protocol/openid-connect/token
               userinfo_endpoint: http://localhost:8090/realms/hanko/protocol/openid-connect/userinfo
               scopes:
                 - openid
                 - email
                 - profile
              attribute_mapping:
                preferred_username: test_username
    
  4. Client hanko2 uses default settings, should also return email and profile information but also includes the address (which was added to the Hanko Claims struct, see implementation description above). To request the address and see that it works, also request the address scope:

        keycloak_two:
        allow_linking: true
        enabled: true
        secret: yBiUN65eKErWPsQyioarxvVpau2SvAlm
        client_id: hanko2
        display_name: Keycloak Two Custom
        use_discovery: true
        issuer: http://localhost:8090/realms/hanko
        scopes:
          - openid
          - email
          - profile
          - address
    
  5. Start the backend and one of the frontend/examples. Log in with the provider. The hanko realm has one user: Username: test, PW: test.

  6. Verify sign-up/sign-in/linking works. View the identites.data column in the DB for claims.

  7. In order to understand assumptions made regarding required/optional attributes of a custom_provider: view the validation logic in the config.go file and try to generate the schema (via cmd schema generate config) and observe its effects on config file validation in your IDE (schema for the config must be configured accordingly).

- New configuration option `third_party.custom_providers`. `custom_providers`
  is a map of arbitrarily chosen keys to a `CustomThirdPartyProvider` - this is
  implemented as a new type differing from the existing configuration type
  `ThirdPartyProvider` used for built-in providers because they have different
  configuration requirements.

- Both `ThirdPartyProvider` and `CustomThirdPartyProvider` types get a non-
  configurable, automatically populated `Name` (during the config's `PostProcess`)
  that sort of serves as an identifier/slug for the provider in order to
  distinguish provider types at runtime.
    - A `CustomThirdPartyProvider`s `Name` is automatically prefixed during
      `PostProcess` with a "custom_" prefix to ensure that providers can be
      distinguished at runtime.
    - A (built-in) `ThirdPartyProvider`s `Name` is "hard-coded" through the
      `DefaultConfig`.

- Built-in OAuth/OIDC provider implementations are currently instantiated
  on-demand instead of once at appliation startup (i.e. unlike SAML
  providers) - i.e. when a user requests auth/authz with a third party
  provider, only then a provider is instantiated and created via factory
  function (`thirdparty.GetProvider`).  Custom providers follow this
  pattern, hence the factory function had to be adjusted to take into account
  providers with the aforementioned "custom_" prefix (i.e.: if it is a
  "custom_" provider, instantiate a `customProvider` implementation).

- The `customProvider` implementation uses the `go-oidc` library. Instances
  of providers of the type this library offers can be instantiated by passing
  in an `issuer` URL. Such an instantiation automatically attempts to retrieve
  an OIDC discovery document from a `.well-known` endpoint. This also performs
  an issuer validation. Providers configured to not use OIDC discovery (i.e.
  `use_discovery` in the `CustomThirdPartyProvider` is `false` or omitted) do
  not do this issuer check.

- The `customProvider` implementation is further based on the assumption that
  provider user data is only extracted from a userinfo endpoint response, i.e.
  in case of an OIDC provider, the implementation does not make use of the ID
  token - no validation is performed on the ID token.

- The `customProvider` implementation requires configuring a list of `scopes`:
  because the custom providers allow configuring both OAuth as well as OIDC
  providers, we cannot simply set a default set of scopes, e.g. `openid`, which
  is a required claim for OIDC - some providers return errors on unknown claims
  so setting this would make the third party auth process prone to errors.

- The `customProvider` implementation allows for a simple mapping of claims
  contained in the userinfo response from the provider to "known" standard OIDC
  conformant claims at the Hanko backend (defined in the `thirdparty.Claims`
  struct) through an `attribute_mapping` configuration option. The mapping is a
  simple one-to-one mapping, i.e. no complex mapping instructions are possible,
  e.g. mapping concatenations of multiple claims in the provider data source or
  similar. Any other non-standard claims returned by the provider are placed in
  a `custom_claims` attribute. Except for the user ID (`sub`), `email` and
  `email_verified` claims the third party functionality currently does not allow
  accessing this user data but there's a good chance this will change in the future,
  so I tried to make sure that any info retrieved from the provider is somehow
  preserved (it is persisted in the `data` column for an `identity` btw. and updated
  on every login with the provider).
    - I also noticed that the `thirdparty.Claims` were missing the `address` claim,
      so I added that for completeness' sake.

- The changes also fix a "bug" in the account `linking` logic whereby third party
  connections were established by simply assuming that the email retrieved from the
  provider was verified. So, even if the email address at the provider was not
  verified (or the provider simply did/does not provide info about the verification
  status) an account was created and/or linked and the flow API capabilities of
  automatically triggering a passcode if the backend was configured to require email
  verification would not take effect. This was a wrong assumption and the verification
  status is now based on the actual value retrieved from the provider.
    - In case of a triggered passcode, the changes also modify the token exchange
      action to prevent showing a `back` button/link, since it does not make sense to
      go `back` to anything right after the token exchange - there is nothing to go
      "back" to.
backend/flow_api/flow/shared/action_thirdparty_oauth.go Outdated Show resolved Hide resolved
backend/thirdparty/linking.go Show resolved Hide resolved
backend/thirdparty/linking.go Outdated Show resolved Hide resolved
backend/config/config_third_party.go Outdated Show resolved Hide resolved
backend/config/config_third_party.go Outdated Show resolved Hide resolved
@lfleischmann lfleischmann merged commit 455e8e3 into main Dec 4, 2024
8 checks passed
@lfleischmann lfleischmann deleted the feat-custom-providers branch December 4, 2024 17:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

3 participants