VYPR
Critical severityNVD Advisory· Published Jun 15, 2026

CVE-2026-49757

CVE-2026-49757

Description

AshAuthentication OAuth2/OIDC strategies matched users by email instead of iss/sub, enabling account takeover via provider registration with victim's email.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

AshAuthentication OAuth2/OIDC strategies matched users by email instead of iss/sub, enabling account takeover via provider registration with victim's email.

Vulnerability

AshAuthentication's OAuth2 and OIDC strategies matched local users by email address (via an upsert on the email field or a user-defined sign-in filter) rather than by the OpenID Connect iss/sub claim combination. Per OpenID Connect Core §5.7, only iss/sub uniquely and stably identifies an end-user; other claims, including email, MUST NOT be used as unique identifiers. This affects ash_authentication from 0.1.0 before 4.14.0 and from 5.0.0-rc.0 before 5.0.0-rc.10 [1][2][3][4].

Exploitation

An unauthenticated attacker who can register an account on any accepted OAuth/OIDC provider with the victim's email (including an unverified, reused, or email_verified: false account) can take over the victim's local account. The attacker signs in to the provider with the victim's email; AshAuthentication's register step (Elixir.AshAuthentication.Strategy.OAuth2.IdentityChange:change/3) performs an upsert keyed on email, landing on the victim's existing record. The sign-in preparation (Elixir.AshAuthentication.Strategy.OAuth2.SignInPreparation:prepare/3) does not verify the returned user against an iss/sub identity, so the attacker is authenticated as the victim [4].

Impact

Successful exploitation results in full account takeover of the victim's local user account, granting the attacker all privileges associated with that account [3][4].

Mitigation

The fix requires an identity_resource to persist the provider's iss/sub identity and resolves users by that identity. Only when the provider's email_verified claim is trusted (via trust_email_verified?) is a new sub linked to an existing local account by email. Fixed versions are 4.14.0 and 5.0.0-rc.10. Users unable to upgrade should configure an identity_resource as described in the documentation [1][2][4].

AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

2
728b8d28c1b5

fix!: require an `identity_resource` for all OAuth2 and OIDC strategies

https://github.com/team-alembic/ash_authenticationJames HartonJun 14, 2026via body-scan
51 files changed · +2739 163
  • documentation/dsls/DSL-AshAuthentication.Strategy.Apple.md+3 1 modified
    @@ -77,7 +77,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-apple-registration_enabled?){: #authentication-strategies-apple-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-apple-register_action_name){: #authentication-strategies-apple-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-apple-sign_in_action_name){: #authentication-strategies-apple-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-apple-identity_resource){: #authentication-strategies-apple-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-apple-identity_resource){: #authentication-strategies-apple-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-apple-trust_email_verified?){: #authentication-strategies-apple-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-apple-on_untrusted_email_match){: #authentication-strategies-apple-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-apple-identity_relationship_name){: #authentication-strategies-apple-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-apple-identity_relationship_user_id_attribute){: #authentication-strategies-apple-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-apple-openid_configuration_uri){: #authentication-strategies-apple-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Auth0.md+3 1 modified
    @@ -78,7 +78,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-auth0-registration_enabled?){: #authentication-strategies-auth0-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-auth0-register_action_name){: #authentication-strategies-auth0-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-auth0-sign_in_action_name){: #authentication-strategies-auth0-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-auth0-identity_resource){: #authentication-strategies-auth0-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-auth0-identity_resource){: #authentication-strategies-auth0-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-auth0-trust_email_verified?){: #authentication-strategies-auth0-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-auth0-on_untrusted_email_match){: #authentication-strategies-auth0-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-auth0-identity_relationship_name){: #authentication-strategies-auth0-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-auth0-identity_relationship_user_id_attribute){: #authentication-strategies-auth0-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Github.md+3 1 modified
    @@ -79,7 +79,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-github-registration_enabled?){: #authentication-strategies-github-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-github-register_action_name){: #authentication-strategies-github-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-github-sign_in_action_name){: #authentication-strategies-github-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-github-identity_resource){: #authentication-strategies-github-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-github-identity_resource){: #authentication-strategies-github-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-github-trust_email_verified?){: #authentication-strategies-github-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-github-on_untrusted_email_match){: #authentication-strategies-github-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-github-identity_relationship_name){: #authentication-strategies-github-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-github-identity_relationship_user_id_attribute){: #authentication-strategies-github-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Google.md+3 1 modified
    @@ -81,7 +81,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-google-registration_enabled?){: #authentication-strategies-google-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-google-register_action_name){: #authentication-strategies-google-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-google-sign_in_action_name){: #authentication-strategies-google-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-google-identity_resource){: #authentication-strategies-google-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-google-identity_resource){: #authentication-strategies-google-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-google-trust_email_verified?){: #authentication-strategies-google-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-google-on_untrusted_email_match){: #authentication-strategies-google-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-google-identity_relationship_name){: #authentication-strategies-google-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-google-identity_relationship_user_id_attribute){: #authentication-strategies-google-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.OAuth2.md+3 1 modified
    @@ -258,7 +258,9 @@ OAuth2 authentication
     | [`registration_enabled?`](#authentication-strategies-oauth2-registration_enabled?){: #authentication-strategies-oauth2-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-oauth2-register_action_name){: #authentication-strategies-oauth2-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-oauth2-sign_in_action_name){: #authentication-strategies-oauth2-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-oauth2-identity_resource){: #authentication-strategies-oauth2-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-oauth2-identity_resource){: #authentication-strategies-oauth2-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-oauth2-trust_email_verified?){: #authentication-strategies-oauth2-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-oauth2-on_untrusted_email_match){: #authentication-strategies-oauth2-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-oauth2-identity_relationship_name){: #authentication-strategies-oauth2-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-oauth2-identity_relationship_user_id_attribute){: #authentication-strategies-oauth2-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`icon`](#authentication-strategies-oauth2-icon){: #authentication-strategies-oauth2-icon } | `atom` | `:oauth2` | The name of an icon to use in any potential UI. This is a *hint* for UI generators to use, and not in any way canonical. |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Oidc.md+3 1 modified
    @@ -95,7 +95,9 @@ all the same configuration options should you need them.
     | [`registration_enabled?`](#authentication-strategies-oidc-registration_enabled?){: #authentication-strategies-oidc-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-oidc-register_action_name){: #authentication-strategies-oidc-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-oidc-sign_in_action_name){: #authentication-strategies-oidc-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-oidc-identity_resource){: #authentication-strategies-oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-oidc-identity_resource){: #authentication-strategies-oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-oidc-trust_email_verified?){: #authentication-strategies-oidc-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-oidc-on_untrusted_email_match){: #authentication-strategies-oidc-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-oidc-identity_relationship_name){: #authentication-strategies-oidc-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-oidc-identity_relationship_user_id_attribute){: #authentication-strategies-oidc-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-oidc-openid_configuration_uri){: #authentication-strategies-oidc-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Slack.md+3 1 modified
    @@ -73,7 +73,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-slack-registration_enabled?){: #authentication-strategies-slack-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-slack-register_action_name){: #authentication-strategies-slack-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-slack-sign_in_action_name){: #authentication-strategies-slack-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-slack-identity_resource){: #authentication-strategies-slack-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-slack-identity_resource){: #authentication-strategies-slack-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-slack-trust_email_verified?){: #authentication-strategies-slack-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-slack-on_untrusted_email_match){: #authentication-strategies-slack-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-slack-identity_relationship_name){: #authentication-strategies-slack-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-slack-identity_relationship_user_id_attribute){: #authentication-strategies-slack-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-slack-openid_configuration_uri){: #authentication-strategies-slack-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/tutorials/auth0.md+47 1 modified
    @@ -49,6 +49,7 @@ defmodule MyApp.Accounts.User do
             redirect_uri MyApp.Secrets
             client_secret MyApp.Secrets
             base_url MyApp.Secrets
    +        identity_resource MyApp.Accounts.UserIdentity
           end
         end
       end
    @@ -93,6 +94,51 @@ The values for this configuration should be:
     - `client_secret` the client secret copied from the Auth0 settings page.
     - `base_url` - the "domain" value copied from the Auth0 settings page prefixed with `https://` (eg `https://dev-yu30yo5y4tg2hg0y.us.auth0.com`).
     
    +## The user identity resource
    +
    +OAuth2-based strategies require an `identity_resource` - a resource that stores
    +the provider's `iss` (issuer) and `sub` (subject) claims for each linked
    +account. Matching a returning user by their email address (or any other claim)
    +is **not** safe: per the OpenID Connect specification only the `iss`/`sub`
    +combination uniquely and stably identifies an end-user. The identity resource is
    +where those values live.
    +
    +Returning users are matched by their `iss`/`sub`. Because Auth0 reliably
    +verifies email ownership, `trust_email_verified?` defaults to `true` for this
    +strategy, so a new Auth0 identity whose verified email matches an existing
    +local account is linked to it automatically.
    +
    +Add a `UserIdentity` resource using the `AshAuthentication.UserIdentity`
    +extension. There is no need to define any attributes - the extension generates
    +them for you.
    +
    +```elixir
    +defmodule MyApp.Accounts.UserIdentity do
    +  use Ash.Resource,
    +    data_layer: AshPostgres.DataLayer,
    +    extensions: [AshAuthentication.UserIdentity],
    +    domain: MyApp.Accounts
    +
    +  user_identity do
    +    user_resource MyApp.Accounts.User
    +  end
    +
    +  # Configure your data layer as appropriate for your application.
    +  postgres do
    +    table "user_identities"
    +    repo MyApp.Repo
    +  end
    +end
    +```
    +
    +Don't forget to add it to your domain, and to generate and run migrations for
    +the new resource.
    +
    +```bash
    +mix ash.codegen add_user_identities
    +mix ash.migrate
    +```
    +
     Lastly, we need to add a register action to your user resource. This is defined as an upsert so that it can register new users, or update information for returning users. The default name of the action is `register_with_` followed by the strategy name. In our case that is `register_with_auth0`.
     
     The register action takes two arguments, `user_info` and the `oauth_tokens`.
    @@ -118,7 +164,7 @@ defmodule MyApp.Accounts.User do
           # Required if you have token generation enabled.
           change AshAuthentication.GenerateTokenChange
     
    -      # Required if you have the `identity_resource` configuration enabled.
    +      # Required: persists the provider's `iss`/`sub` identity claims.
           change AshAuthentication.Strategy.OAuth2.IdentityChange
     
           change fn changeset, _ ->
    
  • documentation/tutorials/confirmation.md+78 0 modified
    @@ -349,3 +349,81 @@ defmodule MyApp.Accounts.User do
       end
     end
     ```
    +
    +## Linking an OAuth2/OIDC provider via confirmation
    +
    +OAuth2/OIDC sign-ins are matched to a local user by the provider's `iss`/`sub`,
    +never by email (see the relevant provider tutorial). When a *new* provider
    +identity presents an email that matches an existing account, AshAuthentication
    +will only link it automatically if the provider's `email_verified` claim is
    +trusted (`trust_email_verified?`). Otherwise the email can't be trusted to prove
    +ownership, and what happens is controlled by the strategy's
    +`on_untrusted_email_match` option:
    +
    +  * `:reject` (the default) refuses the sign-in. The user must sign in with their
    +    existing method to link the provider.
    +  * `:confirm` issues a confirmation to the **existing account's** email address
    +    and links the provider only once the recipient proves ownership by
    +    confirming. This requires a `confirmation` add-on (enforced at compile time).
    +
    +```elixir
    +authentication do
    +  strategies do
    +    github do
    +      # ...
    +      identity_resource MyApp.Accounts.UserIdentity
    +      on_untrusted_email_match :confirm
    +    end
    +  end
    +end
    +```
    +
    +With `:confirm`, the OAuth sign-in returns an `AuthenticationFailed` error whose
    +`caused_by` is an `AshAuthentication.Errors.ConfirmationRequired`. Your auth
    +plug/controller can match on that to tell the user to check their email:
    +
    +```elixir
    +case AshAuthentication.Strategy.action(strategy, :register, params) do
    +  {:ok, user} ->
    +    # signed in
    +
    +  {:error, %AshAuthentication.Errors.AuthenticationFailed{
    +     caused_by: %AshAuthentication.Errors.ConfirmationRequired{}
    +   }} ->
    +    # "Confirmation is required before you can sign in via this provider.
    +    #  Please check your email."
    +
    +  {:error, _} ->
    +    # generic failure
    +end
    +```
    +
    +> #### Security: confirm-to-link is a confused-deputy risk {: .warning}
    +>
    +> Confirming binds **whatever provider identity initiated the flow** to the
    +> account. The confirmation email lands in the real owner's inbox (good - an
    +> attacker can't complete it), but a user can still be social-engineered into
    +> confirming a link to an *attacker's* provider account, granting it access.
    +>
    +> The email copy is the mitigation. The sender receives
    +> `opts[:confirmation_type] == :identity_link` and `opts[:provider]` so it can
    +> say exactly which provider is being linked and that confirming grants it
    +> access. The generated confirmation sender branches on this; keep that branch
    +> and make the copy unambiguous:
    +>
    +> ```elixir
    +> def send(user, token, opts) do
    +>   case opts[:confirmation_type] do
    +>     :identity_link ->
    +>       # "Someone signed in with #{opts[:provider]} using your email and wants
    +>       #  to link it to your account. Only confirm if this was you."
    +>
    +>     _ ->
    +>       # normal "confirm your email address" copy
    +>   end
    +> end
    +> ```
    +>
    +> Prefer `:reject` plus an authenticated "connect account" flow for
    +> high-value accounts; `:confirm` trades that safety for not needing a second
    +> sign-in method.
    
  • documentation/tutorials/github.md+47 1 modified
    @@ -52,6 +52,7 @@ defmodule MyApp.Accounts.User do
             client_id MyApp.Secrets
             redirect_uri MyApp.Secrets
             client_secret MyApp.Secrets
    +        identity_resource MyApp.Accounts.UserIdentity
           end
         end
       end
    @@ -95,6 +96,51 @@ The values for this configuration should be:
       (eg `http://localhost:4000/auth`).
     - `client_secret` the client secret copied from the GitHub settings page.
     
    +## The user identity resource
    +
    +OAuth2-based strategies require an `identity_resource` - a resource that stores
    +the provider's `iss` (issuer) and `sub` (subject) claims for each linked
    +account. Matching a returning user by their email address (or any other claim)
    +is **not** safe: per the OpenID Connect specification only the `iss`/`sub`
    +combination uniquely and stably identifies an end-user. The identity resource is
    +where those values live.
    +
    +Returning users are matched by their `iss`/`sub`. Because GitHub reliably
    +verifies email ownership, `trust_email_verified?` defaults to `true` for this
    +strategy, so a new GitHub identity whose verified email matches an existing
    +local account is linked to it automatically.
    +
    +Add a `UserIdentity` resource using the `AshAuthentication.UserIdentity`
    +extension. There is no need to define any attributes - the extension generates
    +them for you.
    +
    +```elixir
    +defmodule MyApp.Accounts.UserIdentity do
    +  use Ash.Resource,
    +    data_layer: AshPostgres.DataLayer,
    +    extensions: [AshAuthentication.UserIdentity],
    +    domain: MyApp.Accounts
    +
    +  user_identity do
    +    user_resource MyApp.Accounts.User
    +  end
    +
    +  # Configure your data layer as appropriate for your application.
    +  postgres do
    +    table "user_identities"
    +    repo MyApp.Repo
    +  end
    +end
    +```
    +
    +Don't forget to add it to your domain, and to generate and run migrations for
    +the new resource.
    +
    +```bash
    +mix ash.codegen add_user_identities
    +mix ash.migrate
    +```
    +
     Lastly, we need to add a register action to your user resource. This is defined
     as an upsert so that it can register new users, or update information for
     returning users. The default name of the action is `register_with_` followed by
    @@ -129,7 +175,7 @@ defmodule MyApp.Accounts.User do
           # Required if you have token generation enabled.
           change AshAuthentication.GenerateTokenChange
     
    -      # Required if you have the `identity_resource` configuration enabled.
    +      # Required: persists the provider's `iss`/`sub` identity claims.
           change AshAuthentication.Strategy.OAuth2.IdentityChange
     
           change fn changeset, _ ->
    
  • documentation/tutorials/google.md+48 1 modified
    @@ -33,13 +33,60 @@ defmodule MyApp.Accounts.User do
             client_id MyApp.Secrets
             redirect_uri MyApp.Secrets
             client_secret MyApp.Secrets
    +        identity_resource MyApp.Accounts.UserIdentity
           end
         end
       end
     end
     ```
     
     Please check the [guide](https://hexdocs.pm/ash_authentication/AshAuthentication.Secret.html) on how to properly configure your Secrets.
    +
    +## The user identity resource
    +
    +OAuth2-based strategies require an `identity_resource` - a resource that stores
    +the provider's `iss` (issuer) and `sub` (subject) claims for each linked
    +account. Matching a returning user by their email address (or any other claim)
    +is **not** safe: per the OpenID Connect specification only the `iss`/`sub`
    +combination uniquely and stably identifies an end-user. The identity resource is
    +where those values live.
    +
    +Returning users are matched by their `iss`/`sub`. Because Google reliably
    +verifies email ownership, `trust_email_verified?` defaults to `true` for this
    +strategy, so a new Google identity whose verified email matches an existing
    +local account is linked to it automatically.
    +
    +Add a `UserIdentity` resource using the `AshAuthentication.UserIdentity`
    +extension. There is no need to define any attributes - the extension generates
    +them for you.
    +
    +```elixir
    +defmodule MyApp.Accounts.UserIdentity do
    +  use Ash.Resource,
    +    data_layer: AshPostgres.DataLayer,
    +    extensions: [AshAuthentication.UserIdentity],
    +    domain: MyApp.Accounts
    +
    +  user_identity do
    +    user_resource MyApp.Accounts.User
    +  end
    +
    +  # Configure your data layer as appropriate for your application.
    +  postgres do
    +    table "user_identities"
    +    repo MyApp.Repo
    +  end
    +end
    +```
    +
    +Don't forget to add it to your domain, and to generate and run migrations for
    +the new resource.
    +
    +```bash
    +mix ash.codegen add_user_identities
    +mix ash.migrate
    +```
    +
     Then we need to define an action that will handle the oauth2 flow, for the google case it is `:register_with_google` it will handle both cases for our resource, user registration & login.
     
     ```elixir
    @@ -59,7 +106,7 @@ defmodule MyApp.Accounts.User do
     
           change AshAuthentication.GenerateTokenChange
     
    -      # Required if you have the `identity_resource` configuration enabled.
    +      # Required: persists the provider's `iss`/`sub` identity claims.
           change AshAuthentication.Strategy.OAuth2.IdentityChange
     
           change fn changeset, _ ->
    
  • documentation/tutorials/slack.md+47 1 modified
    @@ -61,6 +61,7 @@ defmodule MyApp.Accounts.User do
             client_id MyApp.Secrets
             redirect_uri MyApp.Secrets
             client_secret MyApp.Secrets
    +        identity_resource MyApp.Accounts.UserIdentity
           end
         end
       end
    @@ -104,6 +105,51 @@ The values for this configuration should be:
       (eg `http://localhost:4000/auth`).
     - `client_secret` the client secret copied from the Slack settings page.
     
    +## The user identity resource
    +
    +OAuth2-based strategies require an `identity_resource` - a resource that stores
    +the provider's `iss` (issuer) and `sub` (subject) claims for each linked
    +account. Matching a returning user by their email address (or any other claim)
    +is **not** safe: per the OpenID Connect specification only the `iss`/`sub`
    +combination uniquely and stably identifies an end-user. The identity resource is
    +where those values live.
    +
    +Returning users are matched by their `iss`/`sub`. Because Slack verifies email
    +ownership and asserts it via the `email_verified` claim, `trust_email_verified?`
    +defaults to `true` for this strategy, so a new Slack identity whose verified
    +email matches an existing local account is linked to it automatically.
    +
    +Add a `UserIdentity` resource using the `AshAuthentication.UserIdentity`
    +extension. There is no need to define any attributes - the extension generates
    +them for you.
    +
    +```elixir
    +defmodule MyApp.Accounts.UserIdentity do
    +  use Ash.Resource,
    +    data_layer: AshPostgres.DataLayer,
    +    extensions: [AshAuthentication.UserIdentity],
    +    domain: MyApp.Accounts
    +
    +  user_identity do
    +    user_resource MyApp.Accounts.User
    +  end
    +
    +  # Configure your data layer as appropriate for your application.
    +  postgres do
    +    table "user_identities"
    +    repo MyApp.Repo
    +  end
    +end
    +```
    +
    +Don't forget to add it to your domain, and to generate and run migrations for
    +the new resource.
    +
    +```bash
    +mix ash.codegen add_user_identities
    +mix ash.migrate
    +```
    +
     Lastly, we need to add a register action to your user resource. This is defined
     as an upsert so that it can register new users, or update information for
     returning users. The default name of the action is `register_with_` followed by
    @@ -138,7 +184,7 @@ defmodule MyApp.Accounts.User do
           # Required if you have token generation enabled.
           change AshAuthentication.GenerateTokenChange
     
    -      # Required if you have the `identity_resource` configuration enabled.
    +      # Required: persists the provider's `iss`/`sub` identity claims.
           change AshAuthentication.Strategy.OAuth2.IdentityChange
     
           change fn changeset, _ ->
    
  • lib/ash_authentication/add_ons/confirmation/actions.ex+80 0 modified
    @@ -20,6 +20,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
         TokenResource
       }
     
    +  # Reserved `extra_data` key under which an OAuth2/OIDC identity link is stashed
    +  # for `on_untrusted_email_match :confirm`. Namespaced so it cannot collide with
    +  # a monitored field name.
    +  @oauth_identity_key "__oauth_identity__"
    +
       @doc """
       Attempt to confirm a user.
       """
    @@ -147,4 +152,79 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
           _ -> :error
         end
       end
    +
    +  @doc """
    +  Store an OAuth2/OIDC identity link to be applied when the token is confirmed.
    +
    +  Used by `on_untrusted_email_match :confirm`: `payload` (the provider strategy
    +  name, `user_info` and `oauth_tokens`) is stashed in the token's server-side
    +  `extra_data` so that confirming the token links the provider to the account.
    +  """
    +  @spec store_identity_link(Confirmation.t(), String.t(), map, keyword) :: :ok | {:error, any}
    +  def store_identity_link(strategy, token, payload, opts \\ []) do
    +    with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
    +         {:ok, domain} <- TokenResource.Info.token_domain(token_resource),
    +         opts <- opts |> Keyword.put(:upsert?, true) |> Keyword.put_new(:domain, domain),
    +         {:ok, store_changes_action} <-
    +           TokenResource.Info.token_confirmation_store_changes_action_name(token_resource),
    +         {:ok, _token_record} <-
    +           token_resource
    +           |> Changeset.new()
    +           |> Changeset.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })
    +           |> Changeset.for_create(
    +             store_changes_action,
    +             %{
    +               token: token,
    +               extra_data: %{@oauth_identity_key => payload},
    +               purpose: to_string(Strategy.name(strategy))
    +             },
    +             opts
    +           )
    +           |> Ash.create() do
    +      :ok
    +    else
    +      {:error, reason} ->
    +        {:error, reason}
    +
    +      :error ->
    +        {:error,
    +         AssumptionFailed.exception(
    +           message: "Configuration error storing confirmation identity link"
    +         )}
    +    end
    +  end
    +
    +  @doc """
    +  Get a stored OAuth2/OIDC identity link for application when confirming.
    +  """
    +  @spec get_identity_link(Confirmation.t(), String.t(), keyword) :: {:ok, map} | :error
    +  def get_identity_link(strategy, jti, opts \\ []) do
    +    with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
    +         opts <-
    +           Keyword.put_new_lazy(opts, :domain, fn ->
    +             TokenResource.Info.token_domain!(token_resource)
    +           end),
    +         {:ok, get_changes_action} <-
    +           TokenResource.Info.token_confirmation_get_changes_action_name(token_resource),
    +         {:ok, [token_record]} <-
    +           token_resource
    +           |> Query.new()
    +           |> Query.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })
    +           |> Query.set_context(%{strategy: strategy})
    +           |> Query.for_read(get_changes_action, %{"jti" => jti})
    +           |> Ash.read(opts),
    +         payload when is_map(payload) <- Map.get(token_record.extra_data, @oauth_identity_key) do
    +      {:ok, payload}
    +    else
    +      _ -> :error
    +    end
    +  end
     end
    
  • lib/ash_authentication/add_ons/confirmation/confirm_change.ex+36 1 modified
    @@ -8,7 +8,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
       """
     
       use Ash.Resource.Change
    -  alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt}
    +  alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt, UserIdentity}
     
       alias Ash.{
         Changeset,
    @@ -54,6 +54,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
             changeset
             |> Changeset.force_change_attributes(allowed_changes)
             |> Changeset.force_change_attribute(strategy.confirmed_at_field, DateTime.utc_now())
    +        |> maybe_link_identity(strategy, jti, context)
           else
             _ ->
               changeset
    @@ -63,4 +64,38 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
           end
         end)
       end
    +
    +  # `on_untrusted_email_match :confirm`: when the confirmed token carries a
    +  # pending provider identity link, create it once the user is confirmed. The
    +  # token itself is revoked by `Confirmation.Actions.confirm/3`, so the link
    +  # cannot be replayed.
    +  defp maybe_link_identity(changeset, strategy, jti, context) do
    +    case Actions.get_identity_link(strategy, jti, Ash.Context.to_opts(context)) do
    +      {:ok, payload} ->
    +        Changeset.after_action(changeset, fn _changeset, user ->
    +          link_identity(user, payload, context)
    +        end)
    +
    +      :error ->
    +        changeset
    +    end
    +  end
    +
    +  defp link_identity(user, payload, context) do
    +    with {:ok, oauth_strategy} <-
    +           Info.strategy(user.__struct__, String.to_existing_atom(payload["strategy"])),
    +         {:ok, _identity} <-
    +           UserIdentity.Actions.upsert(
    +             oauth_strategy.identity_resource,
    +             %{
    +               user_info: payload["user_info"],
    +               oauth_tokens: payload["oauth_tokens"],
    +               strategy: oauth_strategy.name,
    +               user_id: user.id
    +             },
    +             Ash.Context.to_opts(context)
    +           ) do
    +      {:ok, user}
    +    end
    +  end
     end
    
  • lib/ash_authentication/add_ons/confirmation.ex+31 0 modified
    @@ -173,4 +173,35 @@ defmodule AshAuthentication.AddOn.Confirmation do
           {:ok, token}
         end
       end
    +
    +  @doc """
    +  Generate a confirmation token that links an OAuth2/OIDC provider identity to an
    +  existing account once confirmed.
    +
    +  Issued for `on_untrusted_email_match :confirm`. The token is bound to the
    +  existing `user` (so the confirmation lands in their inbox and confirms against
    +  their account), while `payload` (the provider strategy name, `user_info` and
    +  `oauth_tokens`) is stored server-side and used to create the identity when the
    +  token is confirmed.
    +  """
    +  @spec confirmation_token_for_link(
    +          Confirmation.t(),
    +          Resource.record(),
    +          map,
    +          opts :: Keyword.t()
    +        ) ::
    +          {:ok, String.t()} | :error | {:error, any}
    +  def confirmation_token_for_link(strategy, user, payload, opts \\ []) do
    +    claims = %{"act" => strategy.confirm_action_name}
    +
    +    with {:ok, token, _claims} <-
    +           Jwt.token_for_user(
    +             user,
    +             claims,
    +             Keyword.merge(opts, token_lifetime: strategy.token_lifetime)
    +           ),
    +         :ok <- Confirmation.Actions.store_identity_link(strategy, token, payload, opts) do
    +      {:ok, token}
    +    end
    +  end
     end
    
  • lib/ash_authentication/errors/confirmation_required.ex+27 0 added
    @@ -0,0 +1,27 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Errors.ConfirmationRequired do
    +  @moduledoc """
    +  An OAuth2/OIDC sign-in presented an email matching an existing account, but
    +  the email could not be trusted to prove ownership.
    +
    +  Raised internally to abort the sign-in's upsert without mutating the existing
    +  account. The strategy's `on_untrusted_email_match` is `:confirm`, so the
    +  caller issues a confirmation to the existing account's email and links the
    +  provider identity only once the recipient proves ownership.
    +
    +  The `user`, `user_info` and `oauth_tokens` fields are for internal use by the
    +  caller that issues the confirmation - they are never surfaced to the end user,
    +  to avoid leaking which email addresses are registered.
    +  """
    +  use Splode.Error,
    +    fields: [:strategy, :user, :user_info, :oauth_tokens],
    +    class: :forbidden
    +
    +  @type t :: Exception.t()
    +
    +  @impl true
    +  def message(_), do: "Confirmation required"
    +end
    
  • lib/ash_authentication/strategies/apple/dsl.ex+1 0 modified
    @@ -33,6 +33,7 @@ defmodule AshAuthentication.Strategy.Apple.Dsl do
           auto_set_fields: strategy_fields(Assent.Strategy.Apple, icon: :apple),
           schema: patch_schema(secret_type)
         })
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp patch_schema(secret_type) do
    
  • lib/ash_authentication/strategies/apple/verifier.ex+4 3 modified
    @@ -12,12 +12,13 @@ defmodule AshAuthentication.Strategy.Apple.Verifier do
     
       @doc false
       @spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()}
    -  def verify(strategy, _dsl_state) do
    +  def verify(strategy, dsl_state) do
         with :ok <- validate_secret(strategy, :client_id),
              :ok <- validate_secret(strategy, :team_id),
              :ok <- validate_secret(strategy, :private_key_id),
    -         :ok <- validate_secret(strategy, :private_key_path) do
    -      validate_secret(strategy, :redirect_uri)
    +         :ok <- validate_secret(strategy, :private_key_path),
    +         :ok <- validate_secret(strategy, :redirect_uri) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
         end
       end
     end
    
  • lib/ash_authentication/strategies/auth0/dsl.ex+1 0 modified
    @@ -32,6 +32,7 @@ defmodule AshAuthentication.Strategy.Auth0.Dsl do
           auto_set_fields: [assent_strategy: Auth0, icon: :auth0]
         })
         |> Custom.set_defaults(Auth0.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/custom/verifier.ex+15 10 modified
    @@ -23,17 +23,22 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
         dsl_state
         |> Info.authentication_strategies()
         |> Stream.concat(Info.authentication_add_ons(dsl_state))
    -    |> Enum.reduce_while(:ok, fn
    -      strategy, :ok ->
    -        strategy_module = strategy_module(strategy)
    -
    -        strategy
    -        |> strategy_module.verify(dsl_state)
    -        |> case do
    -          :ok -> {:cont, :ok}
    -          {:error, reason} -> {:halt, {:error, reason}}
    -        end
    +    |> Enum.reduce_while({:ok, []}, fn strategy, {:ok, warnings} ->
    +      strategy_module = strategy_module(strategy)
    +
    +      strategy
    +      |> strategy_module.verify(dsl_state)
    +      |> case do
    +        :ok -> {:cont, {:ok, warnings}}
    +        {:warn, warning} -> {:cont, {:ok, warnings ++ List.wrap(warning)}}
    +        {:error, reason} -> {:halt, {:error, reason}}
    +      end
         end)
    +    |> case do
    +      {:ok, []} -> :ok
    +      {:ok, warnings} -> {:warn, warnings}
    +      {:error, reason} -> {:error, reason}
    +    end
       end
     
       # This is needed by some strategies which re-use another strategy's entity (ie everything based on oauth2).
    
  • lib/ash_authentication/strategies/github/dsl.ex+1 0 modified
    @@ -32,6 +32,7 @@ defmodule AshAuthentication.Strategy.Github.Dsl do
           auto_set_fields: [icon: :github, assent_strategy: Github]
         })
         |> Custom.set_defaults(Github.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/google/dsl.ex+1 0 modified
    @@ -43,6 +43,7 @@ defmodule AshAuthentication.Strategy.Google.Dsl do
           ],
           auth_method: :client_secret_post
         )
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/oauth2/actions.ex+98 7 modified
    @@ -9,8 +9,21 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
       Provides the code interface for working with resources via an OAuth2 strategy.
       """
     
    -  alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
    -  alias AshAuthentication.{Errors, Info, Strategy.OAuth2}
    +  alias Ash.{
    +    Changeset,
    +    Error.Framework.AssumptionFailed,
    +    Error.Invalid.NoSuchAction,
    +    Query,
    +    Resource
    +  }
    +
    +  alias AshAuthentication.{
    +    AddOn.Confirmation,
    +    Errors,
    +    Errors.ConfirmationRequired,
    +    Info,
    +    Strategy.OAuth2
    +  }
     
       @doc """
       Attempt to sign in a user.
    @@ -120,11 +133,17 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
         |> Ash.create()
         |> case do
           {:error, error} ->
    -        {:error,
    -         Errors.AuthenticationFailed.exception(
    -           strategy: strategy,
    -           caused_by: error
    -         )}
    +        case find_confirmation_required(error) do
    +          {:ok, confirmation_required} ->
    +            {:error, confirmation_required(strategy, confirmation_required, options)}
    +
    +          :error ->
    +            {:error,
    +             Errors.AuthenticationFailed.exception(
    +               strategy: strategy,
    +               caused_by: error
    +             )}
    +        end
     
           other ->
             other
    @@ -139,4 +158,76 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
              action: strategy.register_action_name,
              type: :create
            )}
    +
    +  # `on_untrusted_email_match :confirm`: issue a confirmation to the existing
    +  # account's email, then surface a generic `AuthenticationFailed` carrying a
    +  # scrubbed `ConfirmationRequired` as its reason. The plug/controller can match
    +  # on that reason to tell the user to check their email, without the user
    +  # record or provider tokens riding downstream.
    +  defp confirmation_required(strategy, %ConfirmationRequired{} = confirmation_required, opts) do
    +    reason =
    +      case issue_link_confirmation(strategy, confirmation_required, opts) do
    +        :ok -> ConfirmationRequired.exception(strategy: strategy)
    +        {:error, reason} -> reason
    +      end
    +
    +    Errors.AuthenticationFailed.exception(strategy: strategy, caused_by: reason)
    +  end
    +
    +  defp issue_link_confirmation(strategy, %ConfirmationRequired{} = confirmation_required, opts) do
    +    payload = %{
    +      "strategy" => to_string(strategy.name),
    +      "user_info" => confirmation_required.user_info,
    +      "oauth_tokens" => confirmation_required.oauth_tokens
    +    }
    +
    +    with {:ok, confirmation} <- find_confirmation_add_on(strategy.resource),
    +         {:ok, token} <-
    +           Confirmation.confirmation_token_for_link(
    +             confirmation,
    +             confirmation_required.user,
    +             payload,
    +             opts
    +           ) do
    +      {sender, send_opts} = confirmation.sender
    +
    +      send_opts
    +      |> Keyword.put(:tenant, Keyword.get(opts, :tenant))
    +      |> Keyword.put(:confirmation_type, :identity_link)
    +      |> Keyword.put(:provider, strategy.name)
    +      |> then(&sender.send(confirmation_required.user, token, &1))
    +
    +      :ok
    +    end
    +  end
    +
    +  defp find_confirmation_add_on(resource) do
    +    case Enum.find(Info.authentication_add_ons(resource), &match?(%Confirmation{}, &1)) do
    +      nil ->
    +        {:error,
    +         AssumptionFailed.exception(
    +           message:
    +             "`on_untrusted_email_match :confirm` requires a confirmation add-on, but none was found"
    +         )}
    +
    +      confirmation ->
    +        {:ok, confirmation}
    +    end
    +  end
    +
    +  defp find_confirmation_required(%ConfirmationRequired{} = error), do: {:ok, error}
    +
    +  defp find_confirmation_required(%{errors: errors}) when is_list(errors),
    +    do: find_confirmation_required(errors)
    +
    +  defp find_confirmation_required(errors) when is_list(errors) do
    +    Enum.find_value(errors, :error, fn error ->
    +      case find_confirmation_required(error) do
    +        {:ok, _} = found -> found
    +        :error -> false
    +      end
    +    end)
    +  end
    +
    +  defp find_confirmation_required(_), do: :error
     end
    
  • lib/ash_authentication/strategies/oauth2/dsl.ex+13 1 modified
    @@ -156,9 +156,21 @@ defmodule AshAuthentication.Strategy.OAuth2.Dsl do
             identity_resource: [
               type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
               doc:
    -            "The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more.",
    +            "The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more.",
               default: false
             ],
    +        trust_email_verified?: [
    +          type: :boolean,
    +          doc:
    +            "Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email.",
    +          default: false
    +        ],
    +        on_untrusted_email_match: [
    +          type: {:one_of, [:reject, :confirm]},
    +          doc:
    +            "What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account.",
    +          default: :reject
    +        ],
             identity_relationship_name: [
               type: :atom,
               doc: "Name of the relationship to the provider identities resource",
    
  • lib/ash_authentication/strategies/oauth2.ex+28 0 modified
    @@ -239,6 +239,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
         identity_resource: false,
         name: nil,
         nonce: false,
    +    on_untrusted_email_match: :reject,
         prevent_hijacking?: true,
         openid_configuration_uri: nil,
         openid_configuration: nil,
    @@ -255,6 +256,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
         strategy_module: __MODULE__,
         team_id: nil,
         token_url: nil,
    +    trust_email_verified?: false,
         trusted_audiences: nil,
         user_url: nil,
         code_verifier: false,
    @@ -296,6 +298,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
               name: atom,
               prevent_hijacking?: boolean,
               nonce: boolean | secret,
    +          on_untrusted_email_match: :reject | :confirm,
               openid_configuration_uri: nil | binary,
               openid_configuration: nil | map,
               private_key: secret,
    @@ -311,6 +314,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
               strategy_module: module,
               team_id: secret,
               token_url: secret,
    +          trust_email_verified?: boolean,
               trusted_audiences: secret_list,
               user_url: secret,
               code_verifier: secret,
    @@ -321,4 +325,28 @@ defmodule AshAuthentication.Strategy.OAuth2 do
       defdelegate dsl, to: Dsl
       defdelegate transform(strategy, dsl_state), to: Transformer
       defdelegate verify(strategy, dsl_state), to: Verifier
    +
    +  @uid_keys ["uid", "sub", "id", :uid, :sub, :id]
    +
    +  @doc """
    +  Extract the unique provider identifier (the OpenID Connect `sub` claim) from a
    +  provider's `user_info` map.
    +
    +  `uid` is the AshAuthentication convention, `sub` is the OpenID Connect claim,
    +  and `id` is what some providers (eg Google in the past) have returned. The
    +  same extraction must be used both when looking a user up by their identity and
    +  when persisting the identity, so this is the single source of truth.
    +  """
    +  @spec uid_from_user_info(map) :: String.t() | nil
    +  def uid_from_user_info(user_info) do
    +    user_info
    +    |> Map.take(@uid_keys)
    +    |> Map.values()
    +    |> Enum.reject(&is_nil/1)
    +    |> List.first()
    +    |> case do
    +      nil -> nil
    +      uid -> to_string(uid)
    +    end
    +  end
     end
    
  • lib/ash_authentication/strategies/oauth2/identity_change.ex+48 30 modified
    @@ -4,21 +4,30 @@
     
     defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
       @moduledoc """
    -  Updates the identity resource when a user is registered.
    +  Resolves and updates the user's identity when registering via OAuth2/OIDC.
    +
    +  Runs in two phases:
    +
    +    * `before_action` - resolves *which* local user this sign-in belongs to,
    +      using the provider's `iss`/`sub` (never the email). See
    +      `AshAuthentication.Strategy.OAuth2.UserResolver` for the matching rules.
    +    * `after_action` - upserts the identity row for the resolved user so that
    +      future sign-ins with the same `iss`/`sub` resolve to them.
       """
     
       use Ash.Resource.Change
       alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
    -  alias AshAuthentication.{Info, Strategy, UserIdentity}
    +  alias AshAuthentication.{Info, Strategy, Strategy.OAuth2, UserIdentity}
       import AshAuthentication.Utils, only: [is_falsy: 1]
    +  require Ash.Query
     
       @doc false
       @impl true
       @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
    -  def change(changeset, _opts, _context) do
    +  def change(changeset, _opts, context) do
         case Info.strategy_for_action(changeset.resource, changeset.action.name) do
           {:ok, strategy} ->
    -        do_change(changeset, strategy)
    +        do_change(changeset, strategy, context)
     
           :error ->
             {:error,
    @@ -28,37 +37,46 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
         end
       end
     
    -  defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset
    +  defp do_change(changeset, strategy, _context) when is_falsy(strategy.identity_resource),
    +    do: changeset
    +
    +  defp do_change(changeset, strategy, context) do
    +    opts = [tenant: context.tenant, actor: context.actor]
     
    -  # sobelow_skip ["DOS.BinToAtom"]
    -  defp do_change(changeset, strategy) do
         changeset
    -    |> Changeset.after_action(fn changeset, user ->
    -      with {:ok, user_id_attribute_name} <-
    -             UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    -           {:ok, _identity} <-
    -             UserIdentity.Actions.upsert(strategy.identity_resource, %{
    +    |> Changeset.before_action(&OAuth2.UserResolver.resolve(&1, strategy, opts))
    +    |> Changeset.after_action(&upsert_identity(&1, &2, strategy, opts))
    +  end
    +
    +  defp upsert_identity(changeset, user, strategy, opts) do
    +    with {:ok, user_id_attribute_name} <-
    +           UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    +         {:ok, _identity} <-
    +           UserIdentity.Actions.upsert(
    +             strategy.identity_resource,
    +             %{
                    user_info: Changeset.get_argument(changeset, :user_info),
                    oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
                    strategy: Strategy.name(strategy),
                    "#{user_id_attribute_name}": user.id
    -             }) do
    -        user
    -        |> Ash.load(
    -          [
    -            {strategy.identity_relationship_name,
    -             Ash.Query.new(strategy.identity_resource)
    -             |> Ash.Query.set_context(%{
    -               private: %{
    -                 ash_authentication?: true
    -               }
    -             })}
    -          ],
    -          domain: Info.domain!(strategy.resource)
    -        )
    -      else
    -        {:error, reason} -> {:error, reason}
    -      end
    -    end)
    +             },
    +             opts
    +           ) do
    +      user
    +      |> Ash.load(
    +        [
    +          {strategy.identity_relationship_name,
    +           Ash.Query.new(strategy.identity_resource)
    +           |> Ash.Query.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })}
    +        ],
    +        Keyword.put(opts, :domain, Info.domain!(strategy.resource))
    +      )
    +    else
    +      {:error, reason} -> {:error, reason}
    +    end
       end
     end
    
  • lib/ash_authentication/strategies/oauth2/sign_in_preparation.ex+89 11 modified
    @@ -16,6 +16,8 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
       use Ash.Resource.Preparation
       alias Ash.{Query, Resource.Preparation}
       alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt, UserIdentity}
    +  alias AshAuthentication.Strategy.OAuth2
    +  alias AshAuthentication.Strategy.OAuth2.UserResolver
       require Ash.Query
       import AshAuthentication.Utils, only: [is_falsy: 1]
     
    @@ -37,10 +39,13 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
              )}
     
           {:ok, strategy} ->
    +        opts = [tenant: context.tenant, actor: context.actor]
    +
             query
             |> Query.after_action(fn
               query, [user] ->
    -            with {:ok, user} <- maybe_update_identity(user, query, strategy) do
    +            with :ok <- verify_identity(user, query, strategy, opts),
    +                 {:ok, user} <- maybe_update_identity(user, query, strategy, opts) do
                   {:ok, [maybe_generate_token(user, context)]}
                 end
     
    @@ -60,17 +65,90 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
         end
       end
     
    -  defp maybe_update_identity(user, _query, strategy) when is_falsy(strategy.identity_resource),
    -    do: {:ok, user}
    +  defp verify_identity(_user, _query, strategy, _opts) when is_falsy(strategy.identity_resource),
    +    do: :ok
    +
    +  defp verify_identity(user, query, strategy, opts) do
    +    user_info = Query.get_argument(query, :user_info)
    +
    +    case OAuth2.uid_from_user_info(user_info) do
    +      nil ->
    +        identity_error(query, strategy, "Provider did not return a stable `sub`/`uid` claim")
    +
    +      uid ->
    +        verify_resolved_identity(user, query, strategy, user_info, uid, opts)
    +    end
    +  end
    +
    +  defp verify_resolved_identity(user, query, strategy, user_info, uid, opts) do
    +    case UserResolver.fetch_identity(strategy, uid, opts) do
    +      {:ok, identity} ->
    +        if identity_belongs_to?(identity, user, strategy) do
    +          :ok
    +        else
    +          identity_error(query, strategy, "Identity is linked to a different user")
    +        end
    +
    +      :error ->
    +        cond do
    +          UserResolver.has_identity_for_strategy?(strategy, user, opts) ->
    +            identity_error(
    +              query,
    +              strategy,
    +              "A different #{strategy.name} identity is already linked to this account"
    +            )
    +
    +          UserResolver.email_trusted?(strategy, user_info) ->
    +            :ok
    +
    +          true ->
    +            identity_error(
    +              query,
    +              strategy,
    +              "Email could not be verified and an account with this email already exists"
    +            )
    +        end
    +    end
    +  end
    +
    +  defp identity_belongs_to?(identity, user, strategy) do
    +    {:ok, user_id_attribute_name} =
    +      UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource)
    +
    +    [pk] = Ash.Resource.Info.primary_key(strategy.resource)
    +
    +    Map.get(identity, user_id_attribute_name) == Map.get(user, pk)
    +  end
    +
    +  defp identity_error(query, strategy, message) do
    +    {:error,
    +     AuthenticationFailed.exception(
    +       strategy: strategy,
    +       query: query,
    +       caused_by: %{
    +         module: __MODULE__,
    +         action: query.action,
    +         strategy: strategy,
    +         message: message
    +       }
    +     )}
    +  end
    +
    +  defp maybe_update_identity(user, _query, strategy, _opts)
    +       when is_falsy(strategy.identity_resource),
    +       do: {:ok, user}
     
    -  defp maybe_update_identity(user, query, strategy) do
    +  defp maybe_update_identity(user, query, strategy, opts) do
         strategy.identity_resource
    -    |> UserIdentity.Actions.upsert(%{
    -      user_info: Query.get_argument(query, :user_info),
    -      oauth_tokens: Query.get_argument(query, :oauth_tokens),
    -      strategy: strategy.name,
    -      user_id: user.id
    -    })
    +    |> UserIdentity.Actions.upsert(
    +      %{
    +        user_info: Query.get_argument(query, :user_info),
    +        oauth_tokens: Query.get_argument(query, :oauth_tokens),
    +        strategy: strategy.name,
    +        user_id: user.id
    +      },
    +      opts
    +    )
         |> case do
           {:ok, _identity} ->
             user
    @@ -84,7 +162,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
                    }
                  })}
               ],
    -          domain: Info.domain!(strategy.resource)
    +          Keyword.put(opts, :domain, Info.domain!(strategy.resource))
             )
     
           {:error, reason} ->
    
  • lib/ash_authentication/strategies/oauth2/user_resolver.ex+248 0 added
    @@ -0,0 +1,248 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Strategy.OAuth2.UserResolver do
    +  @moduledoc """
    +  Resolves which local user an OAuth2/OIDC sign-in belongs to.
    +
    +  Per OpenID Connect Core only the `iss`/`sub` claim combination uniquely and
    +  stably identifies an end-user, so matching is driven by the user identity
    +  resource - **never** by the email address.
    +
    +  Given the changeset for an OAuth2/OIDC register (upsert) action, the rules are:
    +
    +    1. If an identity already exists for this `(strategy, sub)`, the sign-in
    +       belongs to that user. The changeset's upsert keys are rewritten to that
    +       user's values so the upsert resolves to them (and the provider cannot
    +       change a user's email).
    +
    +    2. Otherwise (a `sub` not seen before):
    +       * If no local account has the provider's email - proceed (a new account
    +         is created; if the email is not trusted and a confirmation add-on is
    +         present, that add-on gates it).
    +       * If an account with that email already has an identity for this strategy
    +         (a *different* `sub`) - reject. A single account cannot have two
    +         identities for the same provider auto-linked.
    +       * If the strategy's `email_verified` claim can be trusted
    +         (`trust_email_verified?` and the claim is true) - link the sign-in to
    +         that account.
    +       * Otherwise the email cannot be trusted to prove ownership. With
    +         `on_untrusted_email_match :reject` (the default) the sign-in is
    +         rejected and the user must sign in with their existing method to link
    +         the provider. With `on_untrusted_email_match :confirm` the upsert is
    +         aborted with a `ConfirmationRequired` error so the caller can issue a
    +         confirmation to the existing account's email and link the provider
    +         only once the recipient proves ownership.
    +
    +  Rejections are surfaced as a generic `AuthenticationFailed` error to avoid
    +  leaking which email addresses are registered.
    +  """
    +
    +  alias Ash.Changeset
    +
    +  alias AshAuthentication.{
    +    Errors.AuthenticationFailed,
    +    Errors.ConfirmationRequired,
    +    Info,
    +    Strategy.OAuth2,
    +    UserIdentity
    +  }
    +
    +  require Ash.Query
    +
    +  @doc false
    +  @spec resolve(Changeset.t(), OAuth2.t(), keyword) :: Changeset.t()
    +  def resolve(changeset, strategy, opts \\ []) do
    +    user_info = Changeset.get_argument(changeset, :user_info)
    +
    +    case OAuth2.uid_from_user_info(user_info) do
    +      nil ->
    +        reject(changeset, strategy, "Provider did not return a stable `sub`/`uid` claim")
    +
    +      uid ->
    +        case fetch_identity(strategy, uid, opts) do
    +          {:ok, identity} -> coerce_to_existing_user(changeset, strategy, identity, opts)
    +          :error -> resolve_new_identity(changeset, strategy, user_info, opts)
    +        end
    +    end
    +  end
    +
    +  defp resolve_new_identity(changeset, strategy, user_info, opts) do
    +    case fetch_user_by_upsert_identity(changeset, strategy, opts) do
    +      :error ->
    +        # No local account has this email - allow the upsert to create one.
    +        changeset
    +
    +      {:ok, user} ->
    +        cond do
    +          has_identity_for_strategy?(strategy, user, opts) ->
    +            reject(
    +              changeset,
    +              strategy,
    +              "A different #{strategy.name} identity is already linked to this account"
    +            )
    +
    +          email_trusted?(strategy, user_info) ->
    +            # Verified email matches an existing account - link to it. The email
    +            # already matches so the upsert resolves to this user.
    +            changeset
    +
    +          strategy.on_untrusted_email_match == :confirm ->
    +            require_confirmation(changeset, strategy, user, user_info)
    +
    +          true ->
    +            reject(
    +              changeset,
    +              strategy,
    +              "Email could not be verified and an account with this email already exists"
    +            )
    +        end
    +    end
    +  end
    +
    +  defp require_confirmation(changeset, strategy, user, user_info) do
    +    # Abort the upsert without touching the existing account. The caller issues
    +    # a confirmation to the existing account's email and links the provider only
    +    # once the recipient proves ownership.
    +    Changeset.add_error(
    +      changeset,
    +      ConfirmationRequired.exception(
    +        strategy: strategy,
    +        user: user,
    +        user_info: user_info,
    +        oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens)
    +      )
    +    )
    +  end
    +
    +  defp coerce_to_existing_user(changeset, strategy, identity, opts) do
    +    case load_identity_user(strategy, identity, opts) do
    +      {:ok, user} ->
    +        Enum.reduce(upsert_identity_keys(changeset), changeset, fn key, changeset ->
    +          Changeset.force_change_attribute(changeset, key, Map.get(user, key))
    +        end)
    +
    +      :error ->
    +        # Orphaned identity (user no longer exists) - fall back to the upsert.
    +        changeset
    +    end
    +  end
    +
    +  @doc false
    +  @spec fetch_identity(OAuth2.t(), String.t(), keyword) :: {:ok, Ash.Resource.record()} | :error
    +  def fetch_identity(strategy, uid, opts \\ []) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +
    +    strategy.identity_resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([
    +      {cfg.strategy_attribute_name, to_string(strategy.name)},
    +      {cfg.uid_attribute_name, uid}
    +    ])
    +    |> read_one(identity_domain(strategy), opts)
    +  end
    +
    +  defp load_identity_user(strategy, identity, opts) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +    user_id = Map.get(identity, cfg.user_id_attribute_name)
    +
    +    strategy.resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([{user_pk(strategy), user_id}])
    +    |> read_one(Info.domain!(strategy.resource), opts)
    +  end
    +
    +  defp fetch_user_by_upsert_identity(changeset, strategy, opts) do
    +    keys = upsert_identity_keys(changeset)
    +    values = Enum.map(keys, &{&1, Changeset.get_attribute(changeset, &1)})
    +
    +    if keys == [] or Enum.any?(values, fn {_key, value} -> is_nil(value) end) do
    +      :error
    +    else
    +      strategy.resource
    +      |> base_query(opts)
    +      |> Ash.Query.do_filter(values)
    +      |> read_one(Info.domain!(strategy.resource), opts)
    +    end
    +  end
    +
    +  @doc false
    +  @spec has_identity_for_strategy?(OAuth2.t(), Ash.Resource.record(), keyword) :: boolean
    +  def has_identity_for_strategy?(strategy, user, opts \\ []) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +
    +    strategy.identity_resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([
    +      {cfg.strategy_attribute_name, to_string(strategy.name)},
    +      {cfg.user_id_attribute_name, Map.get(user, user_pk(strategy))}
    +    ])
    +    |> read_one(identity_domain(strategy), opts)
    +    |> case do
    +      {:ok, _identity} -> true
    +      :error -> false
    +    end
    +  end
    +
    +  @doc false
    +  @spec email_trusted?(OAuth2.t(), map) :: boolean
    +  def email_trusted?(%{trust_email_verified?: true}, user_info) do
    +    Map.get(user_info, "email_verified", Map.get(user_info, :email_verified)) in [true, "true"]
    +  end
    +
    +  def email_trusted?(_strategy, _user_info), do: false
    +
    +  defp upsert_identity_keys(changeset) do
    +    with name when not is_nil(name) <- changeset.action.upsert_identity,
    +         identity when not is_nil(identity) <-
    +           Ash.Resource.Info.identity(changeset.resource, name) do
    +      identity.keys
    +    else
    +      _ -> []
    +    end
    +  end
    +
    +  defp user_pk(strategy) do
    +    [pk] = Ash.Resource.Info.primary_key(strategy.resource)
    +    pk
    +  end
    +
    +  defp identity_domain(strategy) do
    +    {:ok, domain} = UserIdentity.Info.user_identity_domain(strategy.identity_resource)
    +    domain
    +  end
    +
    +  defp base_query(resource, opts) do
    +    resource
    +    |> Ash.Query.new()
    +    |> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
    +    |> maybe_set_tenant(opts[:tenant])
    +  end
    +
    +  defp maybe_set_tenant(query, nil), do: query
    +  defp maybe_set_tenant(query, tenant), do: Ash.Query.set_tenant(query, tenant)
    +
    +  defp read_one(query, domain, opts) do
    +    case Ash.read(query, domain: domain, actor: opts[:actor]) do
    +      {:ok, [record | _]} -> {:ok, record}
    +      _ -> :error
    +    end
    +  end
    +
    +  defp reject(changeset, strategy, message) do
    +    Changeset.add_error(
    +      changeset,
    +      AuthenticationFailed.exception(
    +        strategy: strategy,
    +        changeset: changeset,
    +        caused_by: %{
    +          module: __MODULE__,
    +          strategy: strategy,
    +          action: changeset.action.name,
    +          message: message
    +        }
    +      )
    +    )
    +  end
    +end
    
  • lib/ash_authentication/strategies/oauth2/verifier.ex+33 6 modified
    @@ -21,15 +21,42 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
              :ok <- validate_secret(strategy, :base_url),
              :ok <- validate_secret(strategy, :token_url),
              :ok <- validate_secret(strategy, :user_url),
    -         :ok <- prevent_hijacking(dsl_state, strategy) do
    -      if strategy.auth_method == :private_key_jwt do
    -        validate_secret(strategy, :private_key)
    -      else
    -        :ok
    -      end
    +         :ok <- prevent_hijacking(dsl_state, strategy),
    +         :ok <- validate_confirmation_for_untrusted_match(dsl_state, strategy),
    +         :ok <- validate_private_key(strategy) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
    +    end
    +  end
    +
    +  defp validate_confirmation_for_untrusted_match(_dsl_state, %{on_untrusted_email_match: :reject}),
    +    do: :ok
    +
    +  defp validate_confirmation_for_untrusted_match(dsl_state, strategy) do
    +    if Enum.any?(
    +         AshAuthentication.Info.authentication_add_ons(dsl_state),
    +         &(&1.__struct__ == AshAuthentication.AddOn.Confirmation)
    +       ) do
    +      :ok
    +    else
    +      {:error,
    +       DslError.exception(
    +         path: [:authentication, :strategies, strategy.name],
    +         message: """
    +         `on_untrusted_email_match` is set to `:confirm`, but no `confirmation` add-on is configured.
    +
    +         Linking a provider via confirmation requires a confirmation add-on to issue the confirmation
    +         and apply the link once the recipient proves ownership. Add a `confirmation` add-on, or set
    +         `on_untrusted_email_match :reject`.
    +         """
    +       )}
         end
       end
     
    +  defp validate_private_key(%{auth_method: :private_key_jwt} = strategy),
    +    do: validate_secret(strategy, :private_key)
    +
    +  defp validate_private_key(_strategy), do: :ok
    +
       defp prevent_hijacking(_dsl_state, %{prevent_hijacking?: false}), do: :ok
       defp prevent_hijacking(_dsl_state, %{registration_enabled?: false}), do: :ok
     
    
  • lib/ash_authentication/strategies/oidc/verifier.ex+9 7 modified
    @@ -12,16 +12,18 @@ defmodule AshAuthentication.Strategy.Oidc.Verifier do
     
       @doc false
       @spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()}
    -  def verify(strategy, _dsl_state) do
    +  def verify(strategy, dsl_state) do
         with :ok <- validate_secret(strategy, :client_id),
              :ok <- validate_secret(strategy, :client_secret, [nil]),
              :ok <- validate_secret(strategy, :base_url),
    -         :ok <- validate_secret(strategy, :nonce, [true, false]) do
    -      if strategy.auth_method == :private_key_jwt do
    -        validate_secret(strategy, :private_key)
    -      else
    -        :ok
    -      end
    +         :ok <- validate_secret(strategy, :nonce, [true, false]),
    +         :ok <- validate_private_key(strategy) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
         end
       end
    +
    +  defp validate_private_key(%{auth_method: :private_key_jwt} = strategy),
    +    do: validate_secret(strategy, :private_key)
    +
    +  defp validate_private_key(_strategy), do: :ok
     end
    
  • lib/ash_authentication/strategies/slack/dsl.ex+1 0 modified
    @@ -34,6 +34,7 @@ defmodule AshAuthentication.Strategy.Slack.Dsl do
           auto_set_fields: [icon: :slack, assent_strategy: Slack]
         })
         |> Custom.set_defaults(Slack.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
         |> Map.update!(
           :schema,
           fn schema ->
    
  • lib/ash_authentication/user_identity/actions.ex+5 3 modified
    @@ -17,8 +17,8 @@ defmodule AshAuthentication.UserIdentity.Actions do
       @doc """
       Upsert an identity for a user.
       """
    -  @spec upsert(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
    -  def upsert(resource, attributes) do
    +  @spec upsert(Resource.t(), map, keyword) :: {:ok, Resource.record()} | {:error, term}
    +  def upsert(resource, attributes, opts \\ []) do
         with {:ok, domain} <- UserIdentity.Info.user_identity_domain(resource),
              {:ok, upsert_action_name} <-
                UserIdentity.Info.user_identity_upsert_action_name(resource),
    @@ -32,7 +32,9 @@ defmodule AshAuthentication.UserIdentity.Actions do
           })
           |> Changeset.for_create(upsert_action_name, attributes,
             upsert?: true,
    -        upsert_identity: action.upsert_identity
    +        upsert_identity: action.upsert_identity,
    +        tenant: opts[:tenant],
    +        actor: opts[:actor]
           )
           |> Ash.create(domain: domain)
         end
    
  • lib/ash_authentication/user_identity/transformer.ex+15 5 modified
    @@ -51,7 +51,7 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, user_id} <-
    +         {:ok, _user_id} <-
                UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
              {:ok, access_token} <-
                UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
    @@ -69,9 +69,9 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, dsl_state} <-
                maybe_build_attribute(dsl_state, uid, Type.String, allow_nil?: false, writable?: true),
              :ok <- validate_uid_field(dsl_state, uid),
    -         {:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, strategy]),
    +         {:ok, dsl_state} <- maybe_build_identity(dsl_state, [uid, strategy]),
              :ok <-
    -           validate_attribute_unique_constraint(dsl_state, [user_id, uid, strategy], resource),
    +           validate_attribute_unique_constraint(dsl_state, [uid, strategy], resource),
              {:ok, dsl_state} <-
                maybe_build_attribute(dsl_state, access_token, Type.String,
                  allow_nil?: true,
    @@ -226,7 +226,13 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, identity} <- find_identity(dsl_state, [user_id, uid, strategy]),
    +         {:ok, identity} <- find_identity(dsl_state, [uid, strategy]),
    +         {:ok, access_token} <-
    +           UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
    +         {:ok, access_token_expires_at} <-
    +           UserIdentity.Info.user_identity_access_token_expires_at_attribute_name(dsl_state),
    +         {:ok, refresh_token} <-
    +           UserIdentity.Info.user_identity_refresh_token_attribute_name(dsl_state),
              {:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
              {:ok, user_resource_id} <- find_pk(user_resource) do
           arguments = [
    @@ -257,6 +263,10 @@ defmodule AshAuthentication.UserIdentity.Transformer do
             name: action_name,
             upsert?: true,
             upsert_identity: identity.name,
    +        # Only refresh the tokens on conflict. The `user_id` binding is set once,
    +        # on insert, and is never re-pointed - a provider identity belongs to
    +        # exactly one local user, permanently.
    +        upsert_fields: [access_token, access_token_expires_at, refresh_token],
             arguments: arguments,
             changes: changes,
             accept: [strategy]
    @@ -282,7 +292,7 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, identity} <- find_identity(dsl_state, [uid, user_id, strategy]),
    +         {:ok, identity} <- find_identity(dsl_state, [uid, strategy]),
              :ok <- validate_field_in_values(action, :upsert_identity, [identity.name]) do
           :ok
         else
    
  • lib/ash_authentication/user_identity/upsert_identity_change.ex+2 10 modified
    @@ -21,6 +21,7 @@ defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
     
       use Ash.Resource.Change
       alias Ash.{Changeset, Resource.Change}
    +  alias AshAuthentication.Strategy.OAuth2
       alias AshAuthentication.UserIdentity.Info
     
       @doc false
    @@ -33,16 +34,7 @@ defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
         oauth_tokens = Changeset.get_argument(changeset, :oauth_tokens)
         user_id = Changeset.get_argument(changeset, cfg.user_id_attribute_name)
     
    -    uid =
    -      user_info
    -      # uid is a convention
    -      # sub is supposedly from the spec
    -      # id is from what has been seen from Google
    -      |> Map.take(["uid", "sub", "id", :uid, :sub, :id])
    -      |> Map.values()
    -      |> Enum.reject(&is_nil/1)
    -      |> List.first()
    -      |> to_string()
    +    uid = OAuth2.uid_from_user_info(user_info)
     
         changeset
         |> Changeset.change_attribute(cfg.user_id_attribute_name, user_id)
    
  • lib/ash_authentication/validations.ex+69 0 modified
    @@ -206,4 +206,73 @@ defmodule AshAuthentication.Validations do
              )}
         end
       end
    +
    +  @doc """
    +  Collect compile-time warnings for an OAuth2/OIDC strategy.
    +
    +  Returns `{:warn, messages}` (so the configuration still compiles) for the
    +  following safety issues:
    +
    +    * No `identity_resource` is configured. Matching a local user by their email
    +      address (or any other provider-supplied claim) is not safe: per the
    +      OpenID Connect Core specification only the `iss`/`sub` claims uniquely and
    +      stably identify an end-user, and the identity resource is where those are
    +      persisted. This will become a hard requirement in a future release.
    +
    +    * The provider's `email_verified` claim is not trusted
    +      (`trust_email_verified?` is `false`) and no confirmation add-on is
    +      configured. Accounts created via this strategy would carry an unverified
    +      email address with no way to verify ownership.
    +  """
    +  @spec oauth2_strategy_warnings(struct, Dsl.t() | map) :: :ok | {:warn, [String.t()]}
    +  def oauth2_strategy_warnings(strategy, dsl_state) do
    +    [
    +      identity_resource_warning(strategy),
    +      email_verification_warning(strategy, dsl_state)
    +    ]
    +    |> Enum.reject(&is_nil/1)
    +    |> case do
    +      [] -> :ok
    +      warnings -> {:warn, warnings}
    +    end
    +  end
    +
    +  defp identity_resource_warning(%{identity_resource: identity_resource} = strategy)
    +       when is_falsy(identity_resource) do
    +    """
    +    The `#{inspect(strategy.name)}` strategy on `#{inspect(strategy.resource)}` has no `identity_resource` configured.
    +
    +    OAuth2 and OIDC strategies should store the provider's `iss`/`sub` claims in
    +    a user identity resource. Matching a local user by their email address (or
    +    any other provider claim) is unsafe - only the `iss`/`sub` combination
    +    uniquely and stably identifies an end-user. This will become a hard
    +    requirement in a future release.
    +
    +    Run `mix ash_authentication.upgrade` to generate and wire up the required
    +    resource, or see the "User Identities" section of the strategy documentation.
    +    """
    +  end
    +
    +  defp identity_resource_warning(_strategy), do: nil
    +
    +  defp email_verification_warning(%{trust_email_verified?: false} = strategy, dsl_state) do
    +    unless has_confirmation_add_on?(dsl_state) do
    +      """
    +      The `#{inspect(strategy.name)}` strategy on `#{inspect(strategy.resource)}` does not trust the provider's `email_verified` claim (`trust_email_verified?` is `false`) and no confirmation add-on is configured.
    +
    +      Accounts created via this strategy will carry an unverified email address
    +      and there is no way to verify ownership. Add a confirmation add-on that
    +      monitors the email field, or - only for providers that reliably assert
    +      email ownership - set `trust_email_verified? true`.
    +      """
    +    end
    +  end
    +
    +  defp email_verification_warning(_strategy, _dsl_state), do: nil
    +
    +  defp has_confirmation_add_on?(dsl_state) do
    +    dsl_state
    +    |> AshAuthentication.Info.authentication_add_ons()
    +    |> Enum.any?(&(&1.__struct__ == AshAuthentication.AddOn.Confirmation))
    +  end
     end
    
  • lib/mix/tasks/ash_authentication.add_strategy.ex+48 11 modified
    @@ -852,23 +852,46 @@ if Code.ensure_loaded?(Igniter) do
                 alias #{inspect(mailer)}
     
                 @impl true
    -            def send(user, token, _) do
    +            def send(user, token, opts) do
                   new()
                   # TODO: Replace with your email
                   |> from({"noreply", "noreply@example.com"})
                   |> to(to_string(user.email))
    -              |> subject("Confirm your email address")
    -              |> html_body(body([token: token]))
    +              |> subject(subject(opts))
    +              |> html_body(body(token, opts))
                   |> #{List.last(Module.split(mailer))}.deliver!()
                 end
     
    -            defp body(params) do
    -              url = url(~p"/confirm_new_user/\#{params[:token]}")
    +            # `opts[:confirmation_type]` is `:identity_link` when an OAuth2/OIDC
    +            # sign-in whose email matches this already-registered account is asking
    +            # to be linked (the strategy's `on_untrusted_email_match :confirm`).
    +            # Confirming grants that provider login access to this account, so make
    +            # the copy unambiguous about who is asking and what it does.
    +            defp subject(opts) do
    +              case opts[:confirmation_type] do
    +                :identity_link -> "Confirm linking your \#{opts[:provider]} login"
    +                _ -> "Confirm your email address"
    +              end
    +            end
     
    -              """
    -              <p>Click this link to confirm your email:</p>
    -              <p><a href="\#{url}">\#{url}</a></p>
    -              """
    +            defp body(token, opts) do
    +              url = url(~p"/confirm_new_user/\#{token}")
    +
    +              case opts[:confirmation_type] do
    +                :identity_link ->
    +                  """
    +                  <p>Someone signed in with \#{opts[:provider]} using your email address
    +                  and wants to link it to your account.</p>
    +                  <p>If this was you, confirm here: <a href="\#{url}">\#{url}</a></p>
    +                  <p>If it wasn't you, ignore this email - nothing has changed.</p>
    +                  """
    +
    +                _ ->
    +                  """
    +                  <p>Click this link to confirm your email:</p>
    +                  <p><a href="\#{url}">\#{url}</a></p>
    +                  """
    +              end
                 end
                 '''
               )
    @@ -913,10 +936,24 @@ if Code.ensure_loaded?(Igniter) do
             #{use_web_module}
     
             @impl true
    -        def send(_user, token, _) do
    +        def send(_user, token, opts) do
               #{real_example}
    +          # `opts[:confirmation_type]` is `:identity_link` when an OAuth2/OIDC
    +          # sign-in whose email matches this already-registered account is asking
    +          # to be linked (the strategy's `on_untrusted_email_match :confirm`).
    +          # Confirming grants that provider login access to this account.
    +          prompt =
    +            case opts[:confirmation_type] do
    +              :identity_link ->
    +                "Someone signed in with \#{opts[:provider]} using your email address. " <>
    +                  "If it was you, confirm to link it to your account:"
    +
    +              _ ->
    +                "Click this link to confirm your email:"
    +            end
    +
               IO.puts("""
    -          Click this link to confirm your email:
    +          \#{prompt}
     
               #{url}
               """)
    
  • lib/mix/tasks/ash_authentication.upgrade.ex+179 1 modified
    @@ -48,7 +48,8 @@ if Code.ensure_loaded?(Igniter) do
           upgrades =
             %{
               "4.4.9" => [&fix_token_is_revoked_action/2],
    -          "4.13.4" => [&add_remember_me_to_magic_link_sign_in/2]
    +          "4.13.4" => [&add_remember_me_to_magic_link_sign_in/2],
    +          "4.14.0" => [&require_identity_resource/2]
             }
     
           # For each version that requires a change, add it to this map
    @@ -292,6 +293,183 @@ if Code.ensure_loaded?(Igniter) do
     
           Igniter.Code.Common.add_code(zipper, change_code)
         end
    +
    +    @oauth2_family ~w[oauth2 oidc github google auth0 apple slack]a
    +
    +    def require_identity_resource(igniter, _opts) do
    +      case find_resources_with_oauth2_strategies(igniter) do
    +        {igniter, []} ->
    +          igniter
    +
    +        {igniter, resources} ->
    +          resources
    +          |> Enum.reduce(igniter, &ensure_identity_resource/2)
    +          |> Igniter.add_notice("""
    +          The user identity resource's unique key changed from
    +          `(strategy, uid, user_id)` to `(strategy, uid)`, so that a provider's
    +          `iss`/`sub` resolves to exactly one local user.
    +
    +          Run `mix ash.codegen require_user_identity_unique_key` (and then
    +          `mix ash.migrate`) to generate the migration that swaps the unique
    +          index.
    +
    +          IMPORTANT: the new index will fail to create if your data contains the
    +          same `(strategy, uid)` linked to more than one user. That should not
    +          happen under normal use, but if it does you must reconcile those rows
    +          before migrating - it indicates a provider identity was linked to
    +          multiple accounts.
    +          """)
    +      end
    +    end
    +
    +    defp find_resources_with_oauth2_strategies(igniter) do
    +      Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
    +        case enter_auth_strategies(zipper) do
    +          {:ok, zipper} -> Enum.any?(@oauth2_family, &has_strategy?(zipper, &1))
    +          _ -> false
    +        end
    +      end)
    +    end
    +
    +    defp ensure_identity_resource(resource, igniter) do
    +      identity_resource = conventional_identity_resource(resource)
    +
    +      case Igniter.Project.Module.module_exists(igniter, identity_resource) do
    +        {true, igniter} ->
    +          Enum.reduce(
    +            @oauth2_family,
    +            igniter,
    +            &wire_identity_resource(&2, resource, &1, identity_resource)
    +          )
    +
    +        {false, igniter} ->
    +          Igniter.add_warning(
    +            igniter,
    +            missing_identity_resource_warning(resource, identity_resource)
    +          )
    +      end
    +    end
    +
    +    defp wire_identity_resource(igniter, resource, type, identity_resource) do
    +      case AshAuthentication.Igniter.defines_strategy_of_type(igniter, resource, type) do
    +        {igniter, true} ->
    +          igniter
    +          |> add_identity_resource_to_strategy(resource, type, identity_resource)
    +          |> ensure_register_action_has_identity_change(resource, type)
    +
    +        {igniter, false} ->
    +          igniter
    +      end
    +    end
    +
    +    defp conventional_identity_resource(resource) do
    +      resource
    +      |> Module.split()
    +      |> :lists.droplast()
    +      |> Enum.concat(["UserIdentity"])
    +      |> Module.concat()
    +    end
    +
    +    defp add_identity_resource_to_strategy(igniter, resource, type, identity_resource) do
    +      Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
    +        with {:ok, zipper} <- enter_auth_strategies(zipper),
    +             {:ok, strategy_zipper} <-
    +               Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]),
    +             {:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(strategy_zipper),
    +             :error <-
    +               Igniter.Code.Function.move_to_function_call_in_current_scope(
    +                 do_block_zipper,
    +                 :identity_resource,
    +                 1
    +               ) do
    +          {:ok,
    +           Igniter.Code.Common.add_code(
    +             do_block_zipper,
    +             "identity_resource #{inspect(identity_resource)}"
    +           )}
    +        else
    +          _ -> {:ok, zipper}
    +        end
    +      end)
    +    end
    +
    +    # sobelow_skip ["DOS.BinToAtom"]
    +    defp ensure_register_action_has_identity_change(igniter, resource, type) do
    +      action_name = :"register_with_#{type}"
    +
    +      Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
    +        with {:ok, action_zipper} <- move_to_action(zipper, :create, action_name),
    +             {:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
    +             false <- has_identity_change?(do_block_zipper) do
    +          {:ok,
    +           Igniter.Code.Common.add_code(
    +             do_block_zipper,
    +             "change AshAuthentication.Strategy.OAuth2.IdentityChange"
    +           )}
    +        else
    +          _ -> {:ok, zipper}
    +        end
    +      end)
    +    end
    +
    +    defp has_identity_change?(zipper) do
    +      match?(
    +        {:ok, _},
    +        Igniter.Code.Function.move_to_function_call_in_current_scope(
    +          zipper,
    +          :change,
    +          1,
    +          fn change_zipper ->
    +            case Igniter.Code.Function.move_to_nth_argument(change_zipper, 0) do
    +              {:ok, arg_zipper} ->
    +                arg_zipper
    +                |> Sourceror.Zipper.node()
    +                |> Sourceror.to_string()
    +                |> String.contains?("IdentityChange")
    +
    +              _ ->
    +                false
    +            end
    +          end
    +        )
    +      )
    +    end
    +
    +    defp missing_identity_resource_warning(resource, identity_resource) do
    +      """
    +      #{inspect(resource)} has one or more OAuth2/OIDC strategies but no user
    +      identity resource could be found at #{inspect(identity_resource)}.
    +
    +      As of this release, OAuth2 and OIDC strategies require an `identity_resource`.
    +      Matching a local user by their email address (or any other provider claim)
    +      is unsafe - only the provider's `iss`/`sub` claims uniquely and stably
    +      identify an end-user, and those are persisted in the identity resource.
    +
    +      To resolve this manually:
    +
    +        1. Create a user identity resource (conventionally #{inspect(identity_resource)}):
    +
    +           defmodule #{inspect(identity_resource)} do
    +             use Ash.Resource,
    +               extensions: [AshAuthentication.UserIdentity],
    +               domain: <your domain>
    +
    +             user_identity do
    +               user_resource #{inspect(resource)}
    +             end
    +
    +             # ... data layer, postgres/sqlite block, etc.
    +           end
    +
    +        2. Add `identity_resource #{inspect(identity_resource)}` to each OAuth2/OIDC
    +           strategy on #{inspect(resource)}.
    +
    +        3. Add `change AshAuthentication.Strategy.OAuth2.IdentityChange` to each
    +           `register_with_*` action for those strategies.
    +
    +      See the "User Identities" section of the strategy documentation for details.
    +      """
    +    end
       end
     else
       defmodule Mix.Tasks.AshAuthentication.Upgrade do
    
  • mix.lock+31 29 modified
    @@ -1,45 +1,46 @@
     %{
    -  "absinthe": {:hex, :absinthe, "1.9.0", "28f11753d01c0e8b6cb6e764a23cf4081e0e6cae88f53f4c9e4320912aee9c07", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "db65993420944ad90e932827663d4ab704262b007d4e3900cd69615f14ccc8ce"},
    -  "absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
    +  "absinthe": {:hex, :absinthe, "1.10.2", "7951efe6d22c7524752dc5e3fd4289e5bfb1d1e9ee2da2c79431c585552ded2e", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3948d6948c45b5cfd375892e578943eac8642d0a34b15e2a92ffdcdda9d91a22"},
    +  "absinthe_plug": {:hex, :absinthe_plug, "1.5.10", "c9e207235aaa8a086a5db6801a9bebaea035f7b5a2703cb98d962646ef70c76f", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "489ac1951c8e4128571141c60a0669a720619bc161f801a8c6be8cfaf7ab0979"},
       "argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
    -  "ash": {:hex, :ash, "3.11.3", "0ba9bb36ed6ee3141a7c08a37850b8c80ff7cccbc50e1e44222dd165ae05550a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e219c4e2c975523efa0af209dea27eb38384054e191ce3334e0b5ca3e5bdcebe"},
    +  "ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
       "ash_graphql": {:hex, :ash_graphql, "1.8.4", "31a26e62e6e5efa6860316d9b088a919b16f8abcc78e6fc18eee15d12baa19d0", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "c31f81f8aef58a120875773f69d188c69cc871b970d49ec64550a0addfba0c82"},
       "ash_json_api": {:hex, :ash_json_api, "1.5.1", "0c27805bcb797122786f259baca986681b1b5924c5fa547f03a06c35b054673c", [:mix], [{:ash, ">= 3.4.69 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.3.58 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4", [hex: :json_xema, repo: "hexpm", optional: false]}, {:open_api_spex, "~> 3.16", [hex: :open_api_spex, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "767c38eb25e58ac48a021194d5a5f395fc1de6b4c98de04cbb9b3020307301f1"},
       "ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
       "ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
       "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
       "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
       "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
    -  "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
    +  "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
       "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
       "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
    -  "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
    +  "cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
       "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
    -  "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
    +  "cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
       "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
    -  "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
    -  "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
    -  "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
    +  "crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
    +  "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
    +  "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
       "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
       "doctor": {:hex, :doctor, "0.22.0", "223e1cace1f16a38eda4113a5c435fa9b10d804aa72d3d9f9a71c471cc958fe7", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "96e22cf8c0df2e9777dc55ebaa5798329b9028889c4023fed3305688d902cd5b"},
       "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
    -  "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
    +  "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
       "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
       "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
       "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
       "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
    +  "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
       "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"},
       "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"},
       "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
    -  "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
    +  "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
       "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
       "git_ops": {:hex, :git_ops, "2.9.0", "b74f6040084f523055b720cc7ef718da47f2cbe726a5f30c2871118635cb91c1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "7fdf84be3490e5692c5dc1f8a1084eed47a221c1063e41938c73312f0bfea259"},
       "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
       "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"},
       "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
    -  "igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
    +  "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"},
       "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
    -  "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
    +  "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
       "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
       "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
       "json_xema": {:hex, :json_xema, "0.6.5", "060459c9c9152650edb4427b1acbc61fa43a23bcea0301d200cafa76e0880f37", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.16", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "b8ffdbc2f67aa8b91b44e1ba0ab77eb5c0b0142116f8fbb804977fb939d470ef"},
    @@ -49,37 +50,38 @@
       "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
       "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
       "mimic": {:hex, :mimic, "2.2.0", "32a0ac9d3e98ac1edbceb770e7c524331fbfc43ca341cf2fe087a508e57e015c", [:mix], [{:ham, "~> 0.3", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "c9766036a11f024fe922a435f851d3e3a7b1da65125b98fb5e36ed792891c45c"},
    -  "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
    +  "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
       "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
    +  "multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
       "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
       "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
       "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
       "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
    -  "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
    +  "phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
       "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
       "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
    -  "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
    -  "plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"},
    +  "plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
    +  "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
       "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
    -  "postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
    +  "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
       "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
    -  "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
    -  "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
    -  "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
    +  "reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
    +  "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
    +  "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
       "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"},
       "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
    -  "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
    -  "spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
    -  "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
    -  "splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
    -  "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
    -  "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
    +  "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
    +  "spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
    +  "spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
    +  "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
    +  "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
    +  "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
       "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
       "usage_rules": {:hex, :usage_rules, "0.1.26", "19d38c8b9b5c35434eae44f7e4554caeb5f08037a1d45a6b059a9782543ac22e", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9f0d203aa288e1b48318929066778ec26fc423fd51f08518c5b47f58ad5caca9"},
       "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
       "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
       "xema": {:hex, :xema, "0.17.5", "63874e29be626f7162d1e3f68d481e04442ce2438b4f4466f6b51dc9b763b45d", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b49bffe49a565ceeb6dcecbbed7044ccdea934d0716c77206e7f055f41d550b4"},
       "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
    -  "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
    -  "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
    +  "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
    +  "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
     }
    
  • priv/repo/migrations/20260602011041_sub_iss_identity_key.exs+53 0 added
    @@ -0,0 +1,53 @@
    +defmodule Example.Repo.Migrations.SubIssIdentityKey do
    +  @moduledoc """
    +  Updates resources based on their most recent snapshots.
    +
    +  This file was autogenerated with `mix ash_postgres.generate_migrations`
    +  """
    +
    +  use Ecto.Migration
    +
    +  def up do
    +    drop_if_exists(
    +      unique_index(:user_identities, [:strategy, :uid, :user_id],
    +        name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +      )
    +    )
    +
    +    create unique_index(:user_identities, [:strategy, :uid],
    +             name: "user_identities_unique_on_strategy_and_uid_index"
    +           )
    +
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:strategy, :uid, :user_id],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:strategy, :uid],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +           )
    +  end
    +
    +  def down do
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:strategy, :uid],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:strategy, :uid, :user_id],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +           )
    +
    +    drop_if_exists(
    +      unique_index(:user_identities, [:strategy, :uid],
    +        name: "user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:user_identities, [:strategy, :uid, :user_id],
    +             name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +           )
    +  end
    +end
    
  • priv/repo/migrations/20260603040610_multitenant_user_identity.exs+33 0 added
    @@ -0,0 +1,33 @@
    +defmodule Example.Repo.Migrations.MultitenantUserIdentity do
    +  @moduledoc """
    +  Updates resources based on their most recent snapshots.
    +
    +  This file was autogenerated with `mix ash_postgres.generate_migrations`
    +  """
    +
    +  use Ecto.Migration
    +
    +  def up do
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:strategy, :uid],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:organisation_id, :strategy, :uid],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +           )
    +  end
    +
    +  def down do
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:organisation_id, :strategy, :uid],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:strategy, :uid],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +           )
    +  end
    +end
    
  • priv/resource_snapshots/repo/mt_user_identities/20260602011041.json+172 0 added
    @@ -0,0 +1,172 @@
    +{
    +  "attributes": [
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "refresh_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token_expires_at",
    +      "type": "utc_datetime_usec"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "uid",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "strategy",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "fragment(\"gen_random_uuid()\")",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": true,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "organisation_id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_user_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_user"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "user_id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_organisation_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_organisations"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "organisation_id",
    +      "type": "uuid"
    +    }
    +  ],
    +  "base_filter": null,
    +  "check_constraints": [],
    +  "custom_indexes": [],
    +  "custom_statements": [],
    +  "has_create_action": true,
    +  "hash": "B2F4F4AC0BDA249D261D7F4B98C1562BA8B5DC903E7D999728383C2062D6B1C8",
    +  "identities": [
    +    {
    +      "all_tenants?": false,
    +      "base_filter": null,
    +      "index_name": "mt_user_identities_unique_on_strategy_and_uid_index",
    +      "keys": [
    +        {
    +          "type": "atom",
    +          "value": "strategy"
    +        },
    +        {
    +          "type": "atom",
    +          "value": "uid"
    +        }
    +      ],
    +      "name": "unique_on_strategy_and_uid",
    +      "nils_distinct?": true,
    +      "where": null
    +    }
    +  ],
    +  "multitenancy": {
    +    "attribute": null,
    +    "global": null,
    +    "strategy": null
    +  },
    +  "repo": "Elixir.Example.Repo",
    +  "schema": null,
    +  "table": "mt_user_identities"
    +}
    \ No newline at end of file
    
  • priv/resource_snapshots/repo/mt_user_identities/20260603040610.json+172 0 added
    @@ -0,0 +1,172 @@
    +{
    +  "attributes": [
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "refresh_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token_expires_at",
    +      "type": "utc_datetime_usec"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "uid",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "strategy",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "fragment(\"gen_random_uuid()\")",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": true,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "organisation_id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_user_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_user"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "user_id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_organisation_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_organisations"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "organisation_id",
    +      "type": "uuid"
    +    }
    +  ],
    +  "base_filter": null,
    +  "check_constraints": [],
    +  "custom_indexes": [],
    +  "custom_statements": [],
    +  "has_create_action": true,
    +  "hash": "1E3A321ADEC0012EC3B0FCDB5258435DAA449C9086D1B02BED31FDE81E0E6F66",
    +  "identities": [
    +    {
    +      "all_tenants?": false,
    +      "base_filter": null,
    +      "index_name": "mt_user_identities_unique_on_strategy_and_uid_index",
    +      "keys": [
    +        {
    +          "type": "atom",
    +          "value": "strategy"
    +        },
    +        {
    +          "type": "atom",
    +          "value": "uid"
    +        }
    +      ],
    +      "name": "unique_on_strategy_and_uid",
    +      "nils_distinct?": true,
    +      "where": null
    +    }
    +  ],
    +  "multitenancy": {
    +    "attribute": "organisation_id",
    +    "global": true,
    +    "strategy": "attribute"
    +  },
    +  "repo": "Elixir.Example.Repo",
    +  "schema": null,
    +  "table": "mt_user_identities"
    +}
    \ No newline at end of file
    
  • priv/resource_snapshots/repo/user_identities/20260602011041.json+141 0 added
    @@ -0,0 +1,141 @@
    +{
    +  "attributes": [
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "refresh_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token_expires_at",
    +      "type": "utc_datetime_usec"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "uid",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "strategy",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "fragment(\"gen_random_uuid()\")",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": true,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": null,
    +          "global": null,
    +          "strategy": null
    +        },
    +        "name": "user_identities_user_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "user"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "user_id",
    +      "type": "uuid"
    +    }
    +  ],
    +  "base_filter": null,
    +  "check_constraints": [],
    +  "custom_indexes": [],
    +  "custom_statements": [],
    +  "has_create_action": true,
    +  "hash": "6EEE034706EAFC19E792DDB90C16D8396C74839F5274BB86C259AF1C3B98C327",
    +  "identities": [
    +    {
    +      "all_tenants?": false,
    +      "base_filter": null,
    +      "index_name": "user_identities_unique_on_strategy_and_uid_index",
    +      "keys": [
    +        {
    +          "type": "atom",
    +          "value": "strategy"
    +        },
    +        {
    +          "type": "atom",
    +          "value": "uid"
    +        }
    +      ],
    +      "name": "unique_on_strategy_and_uid",
    +      "nils_distinct?": true,
    +      "where": null
    +    }
    +  ],
    +  "multitenancy": {
    +    "attribute": null,
    +    "global": null,
    +    "strategy": null
    +  },
    +  "repo": "Elixir.Example.Repo",
    +  "schema": null,
    +  "table": "user_identities"
    +}
    \ No newline at end of file
    
  • test/ash_authentication/strategies/oauth2/actions_test.exs+213 6 modified
    @@ -6,7 +6,38 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
       @moduledoc false
       use DataCase, async: true
     
    -  alias AshAuthentication.{Info, Jwt, Strategy.OAuth2.Actions}
    +  alias AshAuthentication.{
    +    AddOn.Confirmation,
    +    Errors.AuthenticationFailed,
    +    Errors.ConfirmationRequired,
    +    Errors.InvalidToken,
    +    Info,
    +    Jwt,
    +    Strategy.OAuth2.Actions,
    +    Strategy.OAuth2.UserResolver,
    +    UserIdentity
    +  }
    +
    +  require Ash.Query
    +
    +  defp confirmation_add_on do
    +    Enum.find(
    +      AshAuthentication.Info.authentication_add_ons(Example.User),
    +      &match?(%Confirmation{}, &1)
    +    )
    +  end
    +
    +  defp seed_identity(strategy_name, user, sub) do
    +    {:ok, _identity} =
    +      UserIdentity.Actions.upsert(Example.UserIdentity, %{
    +        user_info: %{"sub" => sub},
    +        oauth_tokens: %{},
    +        strategy: strategy_name,
    +        user_id: user.id
    +      })
    +
    +    :ok
    +  end
     
       describe "sign_in/2" do
         test "it returns an error when registration is enabled" do
    @@ -22,6 +53,7 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
           {:ok, strategy} = Info.strategy(Example.User, :oauth2)
           strategy = %{strategy | registration_enabled?: false}
           user = build_user()
    +      :ok = seed_identity(:oauth2, user, "user:#{user.id}")
     
           assert {:ok, signed_in_user} =
                    Actions.sign_in(
    @@ -50,6 +82,7 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
           {:ok, strategy} = Info.strategy(Example.User, :oauth2)
           strategy = %{strategy | registration_enabled?: false}
           user = build_user()
    +      :ok = seed_identity(:oauth2, user, 1234)
     
           assert {:ok, signed_in_user} =
                    Actions.sign_in(
    @@ -75,9 +108,10 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
                    Jwt.peek(signed_in_user.__metadata__.token)
         end
     
    -    test "it signs in an existing user when registration and identity are disabled" do
    +    test "it signs in an existing user with a registration-disabled strategy" do
           {:ok, strategy} = Info.strategy(Example.User, :oauth2_without_identity)
           user = build_user()
    +      :ok = seed_identity(:oauth2_without_identity, user, "user:#{user.id}")
     
           assert {:ok, signed_in_user} =
                    Actions.sign_in(
    @@ -158,21 +192,31 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
           assert claims["sub"] =~ "user?id=#{user.id}"
         end
     
    -    test "it signs in an existing user when registration is enabled" do
    +    test "it signs in a returning user matched by their existing identity" do
           {:ok, strategy} = Info.strategy(Example.User, :oauth2)
     
           user = build_user()
    -
           Ash.Seed.update!(user, %{confirmed_at: DateTime.utc_now()})
    +      sub = "user:#{user.id}"
    +
    +      {:ok, _identity} =
    +        AshAuthentication.UserIdentity.Actions.upsert(Example.UserIdentity, %{
    +          user_info: %{"sub" => sub},
    +          oauth_tokens: %{},
    +          strategy: :oauth2,
    +          user_id: user.id
    +        })
     
           assert {:ok, signed_in_user} =
                    Actions.register(
                      strategy,
                      %{
    +                   # A different nickname than the user's - matching is by `sub`,
    +                   # not by any provider-supplied attribute.
                        "user_info" => %{
    -                     "nickname" => user.username,
    +                     "nickname" => username(),
                          "uid" => user.id,
    -                     "sub" => "user:#{user.id}"
    +                     "sub" => sub
                        },
                        "oauth_tokens" => %{
                          "access_token" => Ecto.UUID.generate(),
    @@ -187,5 +231,168 @@ defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do
           assert {:ok, claims} = Jwt.peek(signed_in_user.__metadata__.token)
           assert claims["sub"] =~ "user?id=#{user.id}"
         end
    +
    +    test "it rejects a new identity that resolves to an existing account by an untrusted email" do
    +      {:ok, strategy} = Info.strategy(Example.User, :oauth2)
    +
    +      user = build_user()
    +
    +      assert {:error, error} =
    +               Actions.register(
    +                 strategy,
    +                 %{
    +                   "user_info" => %{
    +                     "nickname" => to_string(user.username),
    +                     "uid" => Ecto.UUID.generate(),
    +                     "sub" => "user:#{Ecto.UUID.generate()}"
    +                   },
    +                   "oauth_tokens" => %{
    +                     "access_token" => Ecto.UUID.generate(),
    +                     "expires_in" => 86_400,
    +                     "refresh_token" => Ecto.UUID.generate()
    +                   }
    +                 },
    +                 []
    +               )
    +
    +      assert Exception.message(error) =~ ~r/authentication failed/i
    +    end
    +
    +    test "it requires confirmation, without touching the existing account, when `on_untrusted_email_match` is `:confirm`" do
    +      {:ok, strategy} = Info.strategy(Example.User, :oauth2_confirm_link)
    +
    +      user = build_user()
    +      sub = "user:#{Ecto.UUID.generate()}"
    +      access_token = Ecto.UUID.generate()
    +
    +      assert {:error, %AuthenticationFailed{} = error} =
    +               Actions.register(
    +                 strategy,
    +                 %{
    +                   "user_info" => %{
    +                     "nickname" => to_string(user.username),
    +                     "uid" => Ecto.UUID.generate(),
    +                     "sub" => sub
    +                   },
    +                   "oauth_tokens" => %{
    +                     "access_token" => access_token,
    +                     "expires_in" => 86_400,
    +                     "refresh_token" => Ecto.UUID.generate()
    +                   }
    +                 },
    +                 []
    +               )
    +
    +      # The plug/controller can match on the inner reason to prompt the user to
    +      # check their email; no user record or provider tokens ride downstream.
    +      assert %ConfirmationRequired{} = error.caused_by
    +
    +      # The existing account is untouched: the abort wrote no identity for this sub.
    +      assert {:ok, []} =
    +               Example.UserIdentity
    +               |> Ash.Query.filter(uid == ^sub)
    +               |> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
    +               |> Ash.read()
    +
    +      # A confirmation was issued, bound to the existing account, carrying the
    +      # pending identity link server-side.
    +      assert {:ok, tokens} =
    +               Example.Token
    +               |> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
    +               |> Ash.read()
    +
    +      assert link_token =
    +               Enum.find(tokens, &Map.has_key?(&1.extra_data || %{}, "__oauth_identity__"))
    +
    +      assert link_token.subject == AshAuthentication.user_to_subject(user)
    +      payload = link_token.extra_data["__oauth_identity__"]
    +      assert payload["strategy"] == "oauth2_confirm_link"
    +      assert payload["user_info"]["sub"] == sub
    +      assert payload["oauth_tokens"]["access_token"] == access_token
    +    end
    +
    +    test "confirming a link token links the provider to the existing account, and can't be replayed" do
    +      {:ok, strategy} = Info.strategy(Example.User, :oauth2_confirm_link)
    +      confirmation = confirmation_add_on()
    +
    +      user = build_user()
    +      sub = "user:#{Ecto.UUID.generate()}"
    +
    +      payload = %{
    +        "strategy" => "oauth2_confirm_link",
    +        "user_info" => %{"sub" => sub},
    +        "oauth_tokens" => %{"access_token" => Ecto.UUID.generate()}
    +      }
    +
    +      {:ok, token} = Confirmation.confirmation_token_for_link(confirmation, user, payload, [])
    +
    +      # Not linked until the recipient proves ownership by confirming.
    +      assert :error = UserResolver.fetch_identity(strategy, sub)
    +
    +      assert {:ok, _confirmed} = Confirmation.Actions.confirm(confirmation, %{"confirm" => token})
    +
    +      # The provider identity is now bound to the existing account.
    +      assert {:ok, identity} = UserResolver.fetch_identity(strategy, sub)
    +      assert identity.user_id == user.id
    +
    +      # The confirmation is single-use: replaying the token is rejected.
    +      assert {:error, %InvalidToken{}} =
    +               Confirmation.Actions.confirm(confirmation, %{"confirm" => token})
    +    end
    +
    +    test "it links a new identity to an existing account when the email is trusted" do
    +      {:ok, strategy} = Info.strategy(Example.User, :github)
    +      user = build_user()
    +      Ash.Seed.update!(user, %{confirmed_at: DateTime.utc_now()})
    +
    +      assert {:ok, signed_in_user} =
    +               Actions.register(
    +                 strategy,
    +                 %{
    +                   "user_info" => %{
    +                     "nickname" => to_string(user.username),
    +                     "uid" => Ecto.UUID.generate(),
    +                     "sub" => "gh:#{Ecto.UUID.generate()}",
    +                     "email_verified" => true
    +                   },
    +                   "oauth_tokens" => %{
    +                     "access_token" => Ecto.UUID.generate(),
    +                     "expires_in" => 86_400,
    +                     "refresh_token" => Ecto.UUID.generate()
    +                   }
    +                 },
    +                 []
    +               )
    +
    +      assert signed_in_user.id == user.id
    +    end
    +
    +    test "it rejects linking a second identity for the same strategy" do
    +      {:ok, strategy} = Info.strategy(Example.User, :github)
    +      user = build_user()
    +      Ash.Seed.update!(user, %{confirmed_at: DateTime.utc_now()})
    +      :ok = seed_identity(:github, user, "gh:first")
    +
    +      assert {:error, error} =
    +               Actions.register(
    +                 strategy,
    +                 %{
    +                   "user_info" => %{
    +                     "nickname" => to_string(user.username),
    +                     "uid" => Ecto.UUID.generate(),
    +                     "sub" => "gh:second",
    +                     "email_verified" => true
    +                   },
    +                   "oauth_tokens" => %{
    +                     "access_token" => Ecto.UUID.generate(),
    +                     "expires_in" => 86_400,
    +                     "refresh_token" => Ecto.UUID.generate()
    +                   }
    +                 },
    +                 []
    +               )
    +
    +      assert Exception.message(error) =~ ~r/authentication failed/i
    +    end
       end
     end
    
  • test/ash_authentication/strategies/oauth2/multi_tenant_test.exs+104 0 added
    @@ -0,0 +1,104 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Strategy.OAuth2.MultiTenantTest do
    +  @moduledoc false
    +  use DataCase, async: true
    +
    +  alias AshAuthentication.{Info, Strategy.OAuth2.Actions}
    +  alias AshAuthentication.UserIdentity.Actions, as: IdentityActions
    +  alias ExampleMultiTenant.{Organisation, User, UserIdentity}
    +  require Ash.Query
    +
    +  setup do
    +    org_a = Ash.create!(Organisation, %{name: "Org A"}, action: :create)
    +    org_b = Ash.create!(Organisation, %{name: "Org B"}, action: :create)
    +    {:ok, strategy} = Info.strategy(User, :oauth2)
    +
    +    %{org_a: org_a, org_b: org_b, strategy: strategy}
    +  end
    +
    +  defp oauth_params(nickname, sub) do
    +    %{
    +      "user_info" => %{"nickname" => nickname, "uid" => sub, "sub" => sub},
    +      "oauth_tokens" => %{
    +        "access_token" => Ecto.UUID.generate(),
    +        "expires_in" => 86_400,
    +        "refresh_token" => Ecto.UUID.generate()
    +      }
    +    }
    +  end
    +
    +  defp build_user(org, username) do
    +    User
    +    |> Ash.Changeset.for_create(:register_with_password, %{
    +      username: username,
    +      password: "password123",
    +      password_confirmation: "password123",
    +      organisation_id: org.id
    +    })
    +    |> Ash.Changeset.set_tenant(org)
    +    |> Ash.create!()
    +  end
    +
    +  defp identities_for(org, user) do
    +    UserIdentity
    +    |> Ash.Query.filter(user_id == ^user.id)
    +    |> Ash.Query.set_tenant(org)
    +    |> Ash.read!(authorize?: false)
    +  end
    +
    +  test "the same provider identity registers a distinct user in each tenant", ctx do
    +    sub = "shared:#{Ecto.UUID.generate()}"
    +
    +    assert {:ok, user_a} =
    +             Actions.register(ctx.strategy, oauth_params("alice_a", sub), tenant: ctx.org_a)
    +
    +    assert {:ok, user_b} =
    +             Actions.register(ctx.strategy, oauth_params("alice_b", sub), tenant: ctx.org_b)
    +
    +    refute user_a.id == user_b.id
    +    assert user_a.organisation_id == ctx.org_a.id
    +    assert user_b.organisation_id == ctx.org_b.id
    +
    +    # The provider's `sub` resolves independently per tenant: each registration
    +    # creates its own user rather than the second being coerced onto the first.
    +    assert to_string(user_a.username) == "alice_a"
    +    assert to_string(user_b.username) == "alice_b"
    +
    +    assert [identity_a] = identities_for(ctx.org_a, user_a)
    +    assert [identity_b] = identities_for(ctx.org_b, user_b)
    +    assert identity_a.organisation_id == ctx.org_a.id
    +    assert identity_b.organisation_id == ctx.org_b.id
    +  end
    +
    +  test "a returning sign-in resolves to the user within the calling tenant", ctx do
    +    strategy = %{ctx.strategy | registration_enabled?: false}
    +    sub = "shared:#{Ecto.UUID.generate()}"
    +
    +    user_a = build_user(ctx.org_a, "alice_a")
    +    user_b = build_user(ctx.org_b, "alice_b")
    +
    +    seed_identity = fn org, user ->
    +      {:ok, _} =
    +        IdentityActions.upsert(
    +          UserIdentity,
    +          %{user_info: %{"sub" => sub}, oauth_tokens: %{}, strategy: :oauth2, user_id: user.id},
    +          tenant: org
    +        )
    +    end
    +
    +    seed_identity.(ctx.org_a, user_a)
    +    seed_identity.(ctx.org_b, user_b)
    +
    +    assert {:ok, signed_in_a} =
    +             Actions.sign_in(strategy, oauth_params("alice_a", sub), tenant: ctx.org_a)
    +
    +    assert {:ok, signed_in_b} =
    +             Actions.sign_in(strategy, oauth_params("alice_b", sub), tenant: ctx.org_b)
    +
    +    assert signed_in_a.id == user_a.id
    +    assert signed_in_b.id == user_b.id
    +  end
    +end
    
  • test/ash_authentication/strategies/oauth2/verifier_test.exs+145 0 added
    @@ -0,0 +1,145 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Strategy.OAuth2.VerifierTest do
    +  @moduledoc false
    +  use ExUnit.Case, async: true
    +
    +  import Spark.Test
    +
    +  defmodule Domain do
    +    @moduledoc false
    +    use Ash.Domain, validate_config_inclusion?: false
    +
    +    resources do
    +      allow_unregistered? true
    +    end
    +  end
    +
    +  test "an oauth2 strategy without an identity resource warns but still compiles" do
    +    warnings =
    +      dsl_warnings do
    +        defmodule NoIdentityUser do
    +          @moduledoc false
    +          use Ash.Resource,
    +            domain: AshAuthentication.Strategy.OAuth2.VerifierTest.Domain,
    +            extensions: [AshAuthentication],
    +            data_layer: Ash.DataLayer.Ets,
    +            validate_domain_inclusion?: false
    +
    +          attributes do
    +            uuid_primary_key :id
    +            attribute :email, :ci_string, allow_nil?: false, public?: true
    +          end
    +
    +          identities do
    +            identity :unique_email, [:email]
    +          end
    +
    +          actions do
    +            defaults [:read]
    +
    +            create :register_with_oauth2 do
    +              argument :user_info, :map, allow_nil?: false
    +              argument :oauth_tokens, :map, allow_nil?: false
    +              upsert? true
    +              upsert_identity :unique_email
    +              change AshAuthentication.GenerateTokenChange
    +            end
    +          end
    +
    +          authentication do
    +            tokens do
    +              enabled? true
    +              token_resource __MODULE__.Token
    +              signing_secret fn _, _ -> {:ok, "test_secret_that_is_at_least_32_bytes_long"} end
    +            end
    +
    +            strategies do
    +              oauth2 :oauth2 do
    +                client_id fn _, _ -> {:ok, "client_id"} end
    +                client_secret fn _, _ -> {:ok, "client_secret"} end
    +                redirect_uri fn _, _ -> {:ok, "https://example.com"} end
    +                base_url fn _, _ -> {:ok, "https://example.com"} end
    +                authorize_url fn _, _ -> {:ok, "https://example.com/authorize"} end
    +                token_url fn _, _ -> {:ok, "https://example.com/token"} end
    +                user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
    +              end
    +            end
    +          end
    +        end
    +      end
    +
    +    messages =
    +      Enum.flat_map(warnings, fn {_module, payloads} ->
    +        Enum.map(payloads, fn {message, _location} -> message end)
    +      end)
    +
    +    assert Enum.any?(messages, &(&1 =~ "identity_resource"))
    +    assert Enum.any?(messages, &(&1 =~ "mix ash_authentication.upgrade"))
    +
    +    # The generic oauth2 strategy does not trust `email_verified` and there is no
    +    # confirmation add-on, so we also warn that emails cannot be verified.
    +    assert Enum.any?(messages, &(&1 =~ "email_verified"))
    +  end
    +
    +  test "`on_untrusted_email_match :confirm` without a confirmation add-on is a compile error" do
    +    error =
    +      assert_dsl_error %Spark.Error.DslError{path: [:authentication, :strategies, :oauth2]} do
    +        defmodule ConfirmWithoutAddOn do
    +          @moduledoc false
    +          use Ash.Resource,
    +            domain: AshAuthentication.Strategy.OAuth2.VerifierTest.Domain,
    +            extensions: [AshAuthentication],
    +            data_layer: Ash.DataLayer.Ets,
    +            validate_domain_inclusion?: false
    +
    +          attributes do
    +            uuid_primary_key :id
    +            attribute :email, :ci_string, allow_nil?: false, public?: true
    +          end
    +
    +          identities do
    +            identity :unique_email, [:email]
    +          end
    +
    +          actions do
    +            defaults [:read]
    +
    +            create :register_with_oauth2 do
    +              argument :user_info, :map, allow_nil?: false
    +              argument :oauth_tokens, :map, allow_nil?: false
    +              upsert? true
    +              upsert_identity :unique_email
    +              change AshAuthentication.GenerateTokenChange
    +            end
    +          end
    +
    +          authentication do
    +            tokens do
    +              enabled? true
    +              token_resource __MODULE__.Token
    +              signing_secret fn _, _ -> {:ok, "test_secret_that_is_at_least_32_bytes_long"} end
    +            end
    +
    +            strategies do
    +              oauth2 :oauth2 do
    +                client_id fn _, _ -> {:ok, "client_id"} end
    +                client_secret fn _, _ -> {:ok, "client_secret"} end
    +                redirect_uri fn _, _ -> {:ok, "https://example.com"} end
    +                base_url fn _, _ -> {:ok, "https://example.com"} end
    +                authorize_url fn _, _ -> {:ok, "https://example.com/authorize"} end
    +                token_url fn _, _ -> {:ok, "https://example.com/token"} end
    +                user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
    +                on_untrusted_email_match(:confirm)
    +              end
    +            end
    +          end
    +        end
    +      end
    +
    +    assert error.message =~ "confirmation"
    +    assert error.message =~ "on_untrusted_email_match"
    +  end
    +end
    
  • test/mix/tasks/ash_authentication.upgrade_test.exs+246 0 modified
    @@ -409,4 +409,250 @@ defmodule Mix.Tasks.AshAuthentication.UpgradeTest do
           assert_unchanged(igniter, "lib/test/accounts/user.ex")
         end
       end
    +
    +  describe "require_identity_resource/2" do
    +    test "wires up the identity resource when one exists conventionally" do
    +      igniter =
    +        oauth2_project(
    +          strategy: """
    +          oauth2 :oauth2 do
    +            client_id fn _, _ -> {:ok, "client_id"} end
    +            client_secret fn _, _ -> {:ok, "client_secret"} end
    +            redirect_uri fn _, _ -> {:ok, "https://example.com"} end
    +            base_url fn _, _ -> {:ok, "https://example.com"} end
    +            authorize_url fn _, _ -> {:ok, "https://example.com/authorize"} end
    +            token_url fn _, _ -> {:ok, "https://example.com/token"} end
    +            user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end
    +          end
    +          """,
    +          identity_resource?: true
    +        )
    +
    +      igniter = Mix.Tasks.AshAuthentication.Upgrade.require_identity_resource(igniter, [])
    +
    +      igniter
    +      |> assert_has_patch("lib/test/accounts/user.ex", """
    +      + |        identity_resource(Test.Accounts.UserIdentity)
    +      """)
    +      |> assert_has_patch("lib/test/accounts/user.ex", """
    +      + |        change(AshAuthentication.Strategy.OAuth2.IdentityChange)
    +      """)
    +    end
    +
    +    test "warns when no conventional identity resource exists" do
    +      igniter =
    +        oauth2_project(
    +          strategy: """
    +          github :github do
    +            client_id fn _, _ -> {:ok, "client_id"} end
    +            client_secret fn _, _ -> {:ok, "client_secret"} end
    +            redirect_uri fn _, _ -> {:ok, "https://example.com"} end
    +          end
    +          """,
    +          identity_resource?: false
    +        )
    +
    +      igniter = Mix.Tasks.AshAuthentication.Upgrade.require_identity_resource(igniter, [])
    +
    +      igniter
    +      |> assert_unchanged("lib/test/accounts/user.ex")
    +      |> assert_has_warning(fn warning ->
    +        warning =~ "Test.Accounts.UserIdentity"
    +      end)
    +    end
    +
    +    test "does not modify a strategy that already has an identity resource" do
    +      # Written pre-formatted: the upgrader re-renders any module it visits, so a
    +      # no-op only compares equal if the source is already in formatted style.
    +      user_resource = """
    +      defmodule Test.Accounts.User do
    +        use Ash.Resource,
    +          domain: Test.Accounts,
    +          extensions: [AshAuthentication],
    +          data_layer: Ash.DataLayer.Ets
    +
    +        attributes do
    +          uuid_primary_key(:id)
    +          attribute(:email, :ci_string, allow_nil?: false, public?: true)
    +        end
    +
    +        identities do
    +          identity(:unique_email, [:email])
    +        end
    +
    +        actions do
    +          defaults([:read])
    +
    +          create :register_with_oauth2 do
    +            argument(:user_info, :map, allow_nil?: false)
    +            argument(:oauth_tokens, :map, allow_nil?: false)
    +            upsert?(true)
    +            upsert_identity(:unique_email)
    +
    +            change(AshAuthentication.GenerateTokenChange)
    +            change(AshAuthentication.Strategy.OAuth2.IdentityChange)
    +          end
    +        end
    +
    +        authentication do
    +          tokens do
    +            enabled?(true)
    +            token_resource(Test.Accounts.Token)
    +            signing_secret(fn _, _ -> {:ok, "test_secret_that_is_at_least_32_bytes_long"} end)
    +          end
    +
    +          strategies do
    +            oauth2 :oauth2 do
    +              client_id(fn _, _ -> {:ok, "client_id"} end)
    +              client_secret(fn _, _ -> {:ok, "client_secret"} end)
    +              redirect_uri(fn _, _ -> {:ok, "https://example.com"} end)
    +              base_url(fn _, _ -> {:ok, "https://example.com"} end)
    +              authorize_url(fn _, _ -> {:ok, "https://example.com/authorize"} end)
    +              token_url(fn _, _ -> {:ok, "https://example.com/token"} end)
    +              user_url(fn _, _ -> {:ok, "https://example.com/userinfo"} end)
    +              identity_resource(Test.Accounts.UserIdentity)
    +            end
    +          end
    +        end
    +      end
    +      """
    +
    +      token_resource = """
    +      defmodule Test.Accounts.Token do
    +        use Ash.Resource,
    +          domain: Test.Accounts,
    +          extensions: [AshAuthentication.TokenResource],
    +          data_layer: Ash.DataLayer.Ets
    +
    +        token do
    +          api(Test.Accounts)
    +        end
    +      end
    +      """
    +
    +      identity_resource = """
    +      defmodule Test.Accounts.UserIdentity do
    +        use Ash.Resource,
    +          domain: Test.Accounts,
    +          extensions: [AshAuthentication.UserIdentity],
    +          data_layer: Ash.DataLayer.Ets
    +
    +        user_identity do
    +          user_resource(Test.Accounts.User)
    +        end
    +      end
    +      """
    +
    +      igniter =
    +        test_project(
    +          files: %{
    +            "lib/test/accounts/user.ex" => user_resource,
    +            "lib/test/accounts/token.ex" => token_resource,
    +            "lib/test/accounts/user_identity.ex" => identity_resource
    +          }
    +        )
    +
    +      igniter = Mix.Tasks.AshAuthentication.Upgrade.require_identity_resource(igniter, [])
    +
    +      assert_unchanged(igniter, "lib/test/accounts/user.ex")
    +    end
    +  end
    +
    +  defp oauth2_project(opts) do
    +    strategy = Keyword.fetch!(opts, :strategy)
    +    identity_resource? = Keyword.get(opts, :identity_resource?, false)
    +
    +    user_resource = """
    +    defmodule Test.Accounts.User do
    +      use Ash.Resource,
    +        domain: Test.Accounts,
    +        extensions: [AshAuthentication],
    +        data_layer: Ash.DataLayer.Ets
    +
    +      attributes do
    +        uuid_primary_key :id
    +        attribute :email, :ci_string, allow_nil?: false, public?: true
    +      end
    +
    +      identities do
    +        identity :unique_email, [:email]
    +      end
    +
    +      actions do
    +        defaults [:read]
    +
    +        create :register_with_oauth2 do
    +          argument :user_info, :map, allow_nil?: false
    +          argument :oauth_tokens, :map, allow_nil?: false
    +          upsert? true
    +          upsert_identity :unique_email
    +
    +          change AshAuthentication.GenerateTokenChange
    +        end
    +
    +        create :register_with_github do
    +          argument :user_info, :map, allow_nil?: false
    +          argument :oauth_tokens, :map, allow_nil?: false
    +          upsert? true
    +          upsert_identity :unique_email
    +
    +          change AshAuthentication.GenerateTokenChange
    +        end
    +      end
    +
    +      authentication do
    +        tokens do
    +          enabled? true
    +          token_resource Test.Accounts.Token
    +          signing_secret fn _, _ -> {:ok, "test_secret_that_is_at_least_32_bytes_long"} end
    +        end
    +
    +        strategies do
    +          #{strategy}
    +        end
    +      end
    +    end
    +    """
    +
    +    token_resource = """
    +    defmodule Test.Accounts.Token do
    +      use Ash.Resource,
    +        domain: Test.Accounts,
    +        extensions: [AshAuthentication.TokenResource],
    +        data_layer: Ash.DataLayer.Ets
    +
    +      token do
    +        api Test.Accounts
    +      end
    +    end
    +    """
    +
    +    identity_resource = """
    +    defmodule Test.Accounts.UserIdentity do
    +      use Ash.Resource,
    +        domain: Test.Accounts,
    +        extensions: [AshAuthentication.UserIdentity],
    +        data_layer: Ash.DataLayer.Ets
    +
    +      user_identity do
    +        user_resource Test.Accounts.User
    +      end
    +    end
    +    """
    +
    +    files =
    +      %{
    +        "lib/test/accounts/user.ex" => user_resource,
    +        "lib/test/accounts/token.ex" => token_resource
    +      }
    +      |> then(fn files ->
    +        if identity_resource? do
    +          Map.put(files, "lib/test/accounts/user_identity.ex", identity_resource)
    +        else
    +          files
    +        end
    +      end)
    +
    +    test_project(files: files)
    +  end
     end
    
  • test/support/example_multi_tenant/user.ex+4 0 modified
    @@ -220,6 +220,7 @@ defmodule ExampleMultiTenant.User do
             authorization_params scope: "openid profile email"
             auth_method :client_secret_post
             registration_enabled? false
    +        identity_resource ExampleMultiTenant.UserIdentity
           end
     
           auth0 do
    @@ -230,13 +231,15 @@ defmodule ExampleMultiTenant.User do
             authorize_url &get_config/2
             token_url &get_config/2
             user_url &get_config/2
    +        identity_resource ExampleMultiTenant.UserIdentity
           end
     
           github do
             client_id &get_config/2
             redirect_uri &get_config/2
             client_secret &get_config/2
             authorization_params scope: "openid profile email"
    +        identity_resource ExampleMultiTenant.UserIdentity
           end
     
           only_marty do
    @@ -259,6 +262,7 @@ defmodule ExampleMultiTenant.User do
             redirect_uri &get_config/2
             base_url &get_config/2
             trusted_audiences &get_config/2
    +        identity_resource ExampleMultiTenant.UserIdentity
           end
     
           slack do
    
  • test/support/example_multi_tenant/user_identity.ex+6 0 modified
    @@ -21,4 +21,10 @@ defmodule ExampleMultiTenant.UserIdentity do
       relationships do
         belongs_to :organisation, ExampleMultiTenant.Organisation
       end
    +
    +  multitenancy do
    +    strategy :attribute
    +    attribute :organisation_id
    +    global? true
    +  end
     end
    
  • test/support/example/user.ex+35 4 modified
    @@ -121,6 +121,17 @@ defmodule Example.User do
           change AshAuthentication.Strategy.OAuth2.IdentityChange
         end
     
    +    create :register_with_oauth2_confirm_link do
    +      argument :user_info, :map, allow_nil?: false
    +      argument :oauth_tokens, :map, allow_nil?: false
    +      upsert? true
    +      upsert_identity :username
    +
    +      change AshAuthentication.GenerateTokenChange
    +      change Example.GenericOAuth2Change
    +      change AshAuthentication.Strategy.OAuth2.IdentityChange
    +    end
    +
         create :register_with_oidc do
           argument :user_info, :map, allow_nil?: false
           argument :oauth_tokens, :map, allow_nil?: false
    @@ -227,11 +238,12 @@ defmodule Example.User do
             inhibit_updates? true
             require_interaction? true
     
    -        sender fn _user, token, opts ->
    +        sender fn user, token, opts ->
               username =
    -            opts
    -            |> Keyword.fetch!(:changeset)
    -            |> Ash.Changeset.get_attribute(:username)
    +            case Keyword.get(opts, :changeset) do
    +              nil -> user.username
    +              changeset -> Ash.Changeset.get_attribute(changeset, :username)
    +            end
     
               Logger.debug("Confirmation request for user #{username}, token #{inspect(token)}")
             end
    @@ -271,6 +283,21 @@ defmodule Example.User do
             identity_resource Example.UserIdentity
           end
     
    +      oauth2 :oauth2_confirm_link do
    +        client_id &get_config/2
    +        redirect_uri &get_config/2
    +        client_secret &get_config/2
    +        base_url &get_config/2
    +        authorize_url &get_config/2
    +        token_url &get_config/2
    +        trusted_audiences &get_config/2
    +        user_url &get_config/2
    +        authorization_params scope: "openid profile email"
    +        auth_method :client_secret_post
    +        identity_resource Example.UserIdentity
    +        on_untrusted_email_match(:confirm)
    +      end
    +
           oauth2 :oauth2_without_identity do
             client_id &get_config/2
             redirect_uri &get_config/2
    @@ -282,6 +309,7 @@ defmodule Example.User do
             authorization_params scope: "openid profile email"
             auth_method :client_secret_post
             registration_enabled? false
    +        identity_resource Example.UserIdentity
           end
     
           auth0 do
    @@ -292,13 +320,15 @@ defmodule Example.User do
             authorize_url &get_config/2
             token_url &get_config/2
             user_url &get_config/2
    +        identity_resource Example.UserIdentity
           end
     
           github do
             client_id &get_config/2
             redirect_uri &get_config/2
             client_secret &get_config/2
             authorization_params scope: "openid profile email"
    +        identity_resource Example.UserIdentity
           end
     
           only_marty do
    @@ -321,6 +351,7 @@ defmodule Example.User do
             redirect_uri &get_config/2
             base_url &get_config/2
             trusted_audiences &get_config/2
    +        identity_resource Example.UserIdentity
           end
     
           slack do
    
  • usage-rules.md+14 7 modified
    @@ -16,7 +16,7 @@ SPDX-License-Identifier: MIT
     ## Key Principles
     - Always use secrets management - never hardcode credentials
     - Enable tokens for magic_link, confirmation, OAuth2
    -- UserIdentity resource optional for OAuth2 (required for multiple providers per user)
    +- UserIdentity resource required for all OAuth2/OIDC strategies (stores the provider's `iss`/`sub` claims; matching users by email is unsafe)
     - API keys require strict policy controls and expiration management
     - Use prefixes for API keys to enable secret scanning compliance
     - Check existing strategies: `AshAuthentication.Info.strategies/1`
    @@ -33,8 +33,7 @@ SPDX-License-Identifier: MIT
     - Requires: API key resource, relationship to user, sign-in action
     
     **OAuth2** - Social/enterprise login (GitHub, Google, Auth0, Apple, OIDC, Slack)
    -- Requires: custom actions, secrets
    -- Optional: UserIdentity resource (for multiple providers per user)
    +- Requires: custom actions, secrets, UserIdentity resource
     
     ## Password Strategy
     
    @@ -168,9 +167,15 @@ end
     - Custom `register_with_[provider]` action
     - Secrets management
     - Tokens enabled
    +- UserIdentity resource (stores the provider's `iss`/`sub` identity claims - matching users by email or other claims is unsafe)
     
    -**Optional for all OAuth2:**
    -- UserIdentity resource (for multiple providers per user)
    +**How users are matched (sign-in and registration):**
    +- Users are resolved by the `(strategy, sub)` identity, never by email. A provider identity belongs to exactly one local user, permanently.
    +- A `sub` that has been seen before signs that user in.
    +- A new `sub` whose email matches an existing account is only linked automatically when the provider's `email_verified` claim is trusted (`trust_email_verified?`). Otherwise the behaviour depends on `on_untrusted_email_match`.
    +- `trust_email_verified?` defaults to `true` for GitHub/Google/Auth0/Slack/Apple and `false` elsewhere. Only enable it for providers that reliably assert email ownership.
    +- `on_untrusted_email_match` controls the untrusted-email-matches-existing-account case: `:reject` (the default) refuses the sign-in (the user must sign in with their existing method to link the provider); `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership by confirming. `:confirm` requires a `confirmation` add-on (enforced at compile time).
    +- **Security note for `:confirm`**: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear *which* provider is being linked and that confirming grants it access — otherwise a user can be social-engineered into linking an attacker's provider account. The generated confirmation sender branches on `opts[:confirmation_type] == :identity_link` to produce this copy.
     
     ### OAuth2 Configuration Pattern
     ```elixir
    @@ -198,8 +203,8 @@ actions do
         upsert_identity :unique_email
     
         change AshAuthentication.GenerateTokenChange
    -    
    -    # If UserIdentity resource is being used
    +
    +    # Required: persists the provider's `iss`/`sub` identity claims
         change AshAuthentication.Strategy.OAuth2.IdentityChange
     
         change fn changeset, _ctx ->
    @@ -320,6 +325,8 @@ actions do
         upsert_identity :email
     
         change AshAuthentication.GenerateTokenChange
    +    # Required: persists the provider's `iss`/`sub` identity claims
    +    change AshAuthentication.Strategy.OAuth2.IdentityChange
         change fn changeset, _ctx ->
           user_info = Ash.Changeset.get_argument(changeset, :user_info)
     
    
64530644f9b3

fix!: require an `identity_resource` and resolve OAuth2/OIDC users by `iss`/`sub`

https://github.com/team-alembic/ash_authenticationJames HartonJun 8, 2026via body-scan
38 files changed · +1329 148
  • documentation/dsls/DSL-AshAuthentication.Strategy.Apple.md+3 1 modified
    @@ -77,7 +77,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-apple-registration_enabled?){: #authentication-strategies-apple-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-apple-register_action_name){: #authentication-strategies-apple-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-apple-sign_in_action_name){: #authentication-strategies-apple-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-apple-identity_resource){: #authentication-strategies-apple-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-apple-identity_resource){: #authentication-strategies-apple-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-apple-trust_email_verified?){: #authentication-strategies-apple-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-apple-on_untrusted_email_match){: #authentication-strategies-apple-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-apple-identity_relationship_name){: #authentication-strategies-apple-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-apple-identity_relationship_user_id_attribute){: #authentication-strategies-apple-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-apple-openid_configuration_uri){: #authentication-strategies-apple-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Auth0.md+3 1 modified
    @@ -75,7 +75,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-auth0-registration_enabled?){: #authentication-strategies-auth0-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-auth0-register_action_name){: #authentication-strategies-auth0-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-auth0-sign_in_action_name){: #authentication-strategies-auth0-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-auth0-identity_resource){: #authentication-strategies-auth0-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-auth0-identity_resource){: #authentication-strategies-auth0-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-auth0-trust_email_verified?){: #authentication-strategies-auth0-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-auth0-on_untrusted_email_match){: #authentication-strategies-auth0-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-auth0-identity_relationship_name){: #authentication-strategies-auth0-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-auth0-identity_relationship_user_id_attribute){: #authentication-strategies-auth0-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.DynamicOidc.md+3 1 modified
    @@ -127,7 +127,9 @@ the strategy looks up the right row based on the request path
     | [`registration_enabled?`](#authentication-strategies-dynamic_oidc-registration_enabled?){: #authentication-strategies-dynamic_oidc-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-dynamic_oidc-register_action_name){: #authentication-strategies-dynamic_oidc-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-dynamic_oidc-sign_in_action_name){: #authentication-strategies-dynamic_oidc-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-dynamic_oidc-identity_resource){: #authentication-strategies-dynamic_oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-dynamic_oidc-identity_resource){: #authentication-strategies-dynamic_oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-dynamic_oidc-trust_email_verified?){: #authentication-strategies-dynamic_oidc-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-dynamic_oidc-on_untrusted_email_match){: #authentication-strategies-dynamic_oidc-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-dynamic_oidc-identity_relationship_name){: #authentication-strategies-dynamic_oidc-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-dynamic_oidc-identity_relationship_user_id_attribute){: #authentication-strategies-dynamic_oidc-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-dynamic_oidc-openid_configuration_uri){: #authentication-strategies-dynamic_oidc-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Github.md+3 1 modified
    @@ -79,7 +79,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-github-registration_enabled?){: #authentication-strategies-github-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-github-register_action_name){: #authentication-strategies-github-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-github-sign_in_action_name){: #authentication-strategies-github-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-github-identity_resource){: #authentication-strategies-github-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-github-identity_resource){: #authentication-strategies-github-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-github-trust_email_verified?){: #authentication-strategies-github-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-github-on_untrusted_email_match){: #authentication-strategies-github-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-github-identity_relationship_name){: #authentication-strategies-github-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-github-identity_relationship_user_id_attribute){: #authentication-strategies-github-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Google.md+3 1 modified
    @@ -78,7 +78,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-google-registration_enabled?){: #authentication-strategies-google-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-google-register_action_name){: #authentication-strategies-google-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-google-sign_in_action_name){: #authentication-strategies-google-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-google-identity_resource){: #authentication-strategies-google-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-google-identity_resource){: #authentication-strategies-google-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-google-trust_email_verified?){: #authentication-strategies-google-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-google-on_untrusted_email_match){: #authentication-strategies-google-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-google-identity_relationship_name){: #authentication-strategies-google-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-google-identity_relationship_user_id_attribute){: #authentication-strategies-google-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-google-openid_configuration_uri){: #authentication-strategies-google-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Microsoft.md+3 1 modified
    @@ -94,7 +94,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-microsoft-registration_enabled?){: #authentication-strategies-microsoft-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-microsoft-register_action_name){: #authentication-strategies-microsoft-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-microsoft-sign_in_action_name){: #authentication-strategies-microsoft-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-microsoft-identity_resource){: #authentication-strategies-microsoft-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-microsoft-identity_resource){: #authentication-strategies-microsoft-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-microsoft-trust_email_verified?){: #authentication-strategies-microsoft-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-microsoft-on_untrusted_email_match){: #authentication-strategies-microsoft-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-microsoft-identity_relationship_name){: #authentication-strategies-microsoft-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-microsoft-identity_relationship_user_id_attribute){: #authentication-strategies-microsoft-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-microsoft-openid_configuration_uri){: #authentication-strategies-microsoft-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.OAuth2.md+3 1 modified
    @@ -258,7 +258,9 @@ OAuth2 authentication
     | [`registration_enabled?`](#authentication-strategies-oauth2-registration_enabled?){: #authentication-strategies-oauth2-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-oauth2-register_action_name){: #authentication-strategies-oauth2-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-oauth2-sign_in_action_name){: #authentication-strategies-oauth2-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-oauth2-identity_resource){: #authentication-strategies-oauth2-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-oauth2-identity_resource){: #authentication-strategies-oauth2-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-oauth2-trust_email_verified?){: #authentication-strategies-oauth2-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-oauth2-on_untrusted_email_match){: #authentication-strategies-oauth2-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-oauth2-identity_relationship_name){: #authentication-strategies-oauth2-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-oauth2-identity_relationship_user_id_attribute){: #authentication-strategies-oauth2-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`icon`](#authentication-strategies-oauth2-icon){: #authentication-strategies-oauth2-icon } | `atom` | `:oauth2` | The name of an icon to use in any potential UI. This is a *hint* for UI generators to use, and not in any way canonical. |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Oidc.md+3 1 modified
    @@ -95,7 +95,9 @@ all the same configuration options should you need them.
     | [`registration_enabled?`](#authentication-strategies-oidc-registration_enabled?){: #authentication-strategies-oidc-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-oidc-register_action_name){: #authentication-strategies-oidc-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-oidc-sign_in_action_name){: #authentication-strategies-oidc-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-oidc-identity_resource){: #authentication-strategies-oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-oidc-identity_resource){: #authentication-strategies-oidc-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-oidc-trust_email_verified?){: #authentication-strategies-oidc-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-oidc-on_untrusted_email_match){: #authentication-strategies-oidc-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-oidc-identity_relationship_name){: #authentication-strategies-oidc-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-oidc-identity_relationship_user_id_attribute){: #authentication-strategies-oidc-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-oidc-openid_configuration_uri){: #authentication-strategies-oidc-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Okta.md+3 1 modified
    @@ -95,7 +95,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-okta-registration_enabled?){: #authentication-strategies-okta-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-okta-register_action_name){: #authentication-strategies-okta-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-okta-sign_in_action_name){: #authentication-strategies-okta-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-okta-identity_resource){: #authentication-strategies-okta-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-okta-identity_resource){: #authentication-strategies-okta-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-okta-trust_email_verified?){: #authentication-strategies-okta-trust_email_verified? } | `boolean` | `false` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-okta-on_untrusted_email_match){: #authentication-strategies-okta-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-okta-identity_relationship_name){: #authentication-strategies-okta-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-okta-identity_relationship_user_id_attribute){: #authentication-strategies-okta-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-okta-openid_configuration_uri){: #authentication-strategies-okta-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • documentation/dsls/DSL-AshAuthentication.Strategy.Slack.md+3 1 modified
    @@ -73,7 +73,9 @@ The following defaults are applied:
     | [`registration_enabled?`](#authentication-strategies-slack-registration_enabled?){: #authentication-strategies-slack-registration_enabled? } | `boolean` | `true` | If enabled, new users will be able to register for your site when authenticating and not already present. If not, only existing users will be able to authenticate. |
     | [`register_action_name`](#authentication-strategies-slack-register_action_name){: #authentication-strategies-slack-register_action_name } | `atom` |  | The name of the action to use to register a user, if `registration_enabled?` is `true`. Defaults to `register_with_<name>` See the "Registration and Sign-in" section of the strategy docs for more. |
     | [`sign_in_action_name`](#authentication-strategies-slack-sign_in_action_name){: #authentication-strategies-slack-sign_in_action_name } | `atom` |  | The name of the action to use to sign in an existing user, if `sign_in_enabled?` is `true`. Defaults to `sign_in_with_<strategy>`, which is generated for you by default. See the "Registration and Sign-in" section of the strategy docs for more information. |
    -| [`identity_resource`](#authentication-strategies-slack-identity_resource){: #authentication-strategies-slack-identity_resource } | `module \| false` | `false` | The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more. |
    +| [`identity_resource`](#authentication-strategies-slack-identity_resource){: #authentication-strategies-slack-identity_resource } | `module \| false` | `false` | The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more. |
    +| [`trust_email_verified?`](#authentication-strategies-slack-trust_email_verified?){: #authentication-strategies-slack-trust_email_verified? } | `boolean` | `true` | Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email. |
    +| [`on_untrusted_email_match`](#authentication-strategies-slack-on_untrusted_email_match){: #authentication-strategies-slack-on_untrusted_email_match } | `:reject \| :confirm` | `:reject` | What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account. |
     | [`identity_relationship_name`](#authentication-strategies-slack-identity_relationship_name){: #authentication-strategies-slack-identity_relationship_name } | `atom` | `:identities` | Name of the relationship to the provider identities resource |
     | [`identity_relationship_user_id_attribute`](#authentication-strategies-slack-identity_relationship_user_id_attribute){: #authentication-strategies-slack-identity_relationship_user_id_attribute } | `atom` | `:user_id` | The name of the destination (user_id) attribute on your provider identity resource. Only necessary if you've changed the `user_id_attribute_name` option of the provider identity. |
     | [`openid_configuration_uri`](#authentication-strategies-slack-openid_configuration_uri){: #authentication-strategies-slack-openid_configuration_uri } | `(any, any -> any) \| module \| String.t` | `"/.well-known/openid-configuration"` | The URI for the OpenID provider |
    
  • lib/ash_authentication/add_ons/confirmation/actions.ex+80 0 modified
    @@ -20,6 +20,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
         TokenResource
       }
     
    +  # Reserved `extra_data` key under which an OAuth2/OIDC identity link is stashed
    +  # for `on_untrusted_email_match :confirm`. Namespaced so it cannot collide with
    +  # a monitored field name.
    +  @oauth_identity_key "__oauth_identity__"
    +
       @doc """
       Attempt to confirm a user.
       """
    @@ -156,4 +161,79 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
           _ -> :error
         end
       end
    +
    +  @doc """
    +  Store an OAuth2/OIDC identity link to be applied when the token is confirmed.
    +
    +  Used by `on_untrusted_email_match :confirm`: `payload` (the provider strategy
    +  name, `user_info` and `oauth_tokens`) is stashed in the token's server-side
    +  `extra_data` so that confirming the token links the provider to the account.
    +  """
    +  @spec store_identity_link(Confirmation.t(), String.t(), map, keyword) :: :ok | {:error, any}
    +  def store_identity_link(strategy, token, payload, opts \\ []) do
    +    with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
    +         {:ok, domain} <- TokenResource.Info.token_domain(token_resource),
    +         opts <- opts |> Keyword.put(:upsert?, true) |> Keyword.put_new(:domain, domain),
    +         {:ok, store_changes_action} <-
    +           TokenResource.Info.token_confirmation_store_changes_action_name(token_resource),
    +         {:ok, _token_record} <-
    +           token_resource
    +           |> Changeset.new()
    +           |> Changeset.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })
    +           |> Changeset.for_create(
    +             store_changes_action,
    +             %{
    +               token: token,
    +               extra_data: %{@oauth_identity_key => payload},
    +               purpose: to_string(Strategy.name(strategy))
    +             },
    +             opts
    +           )
    +           |> Ash.create() do
    +      :ok
    +    else
    +      {:error, reason} ->
    +        {:error, reason}
    +
    +      :error ->
    +        {:error,
    +         AssumptionFailed.exception(
    +           message: "Configuration error storing confirmation identity link"
    +         )}
    +    end
    +  end
    +
    +  @doc """
    +  Get a stored OAuth2/OIDC identity link for application when confirming.
    +  """
    +  @spec get_identity_link(Confirmation.t(), String.t(), keyword) :: {:ok, map} | :error
    +  def get_identity_link(strategy, jti, opts \\ []) do
    +    with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
    +         opts <-
    +           Keyword.put_new_lazy(opts, :domain, fn ->
    +             TokenResource.Info.token_domain!(token_resource)
    +           end),
    +         {:ok, get_changes_action} <-
    +           TokenResource.Info.token_confirmation_get_changes_action_name(token_resource),
    +         {:ok, [token_record]} <-
    +           token_resource
    +           |> Query.new()
    +           |> Query.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })
    +           |> Query.set_context(%{strategy: strategy})
    +           |> Query.for_read(get_changes_action, %{"jti" => jti})
    +           |> Ash.read(opts),
    +         payload when is_map(payload) <- Map.get(token_record.extra_data, @oauth_identity_key) do
    +      {:ok, payload}
    +    else
    +      _ -> :error
    +    end
    +  end
     end
    
  • lib/ash_authentication/add_ons/confirmation/confirm_change.ex+36 1 modified
    @@ -8,7 +8,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
       """
     
       use Ash.Resource.Change
    -  alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt}
    +  alias AshAuthentication.{AddOn.Confirmation.Actions, Info, Jwt, UserIdentity}
     
       alias Ash.{
         Changeset,
    @@ -57,6 +57,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
           changeset
           |> Changeset.force_change_attributes(allowed_changes)
           |> Changeset.force_change_attribute(strategy.confirmed_at_field, DateTime.utc_now())
    +      |> maybe_link_identity(strategy, jti, context)
         else
           _ ->
             Changeset.add_error(
    @@ -65,4 +66,38 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
             )
         end
       end
    +
    +  # `on_untrusted_email_match :confirm`: when the confirmed token carries a
    +  # pending provider identity link, create it once the user is confirmed. The
    +  # token itself is revoked by `Confirmation.Actions.confirm/3`, so the link
    +  # cannot be replayed.
    +  defp maybe_link_identity(changeset, strategy, jti, context) do
    +    case Actions.get_identity_link(strategy, jti, Ash.Context.to_opts(context)) do
    +      {:ok, payload} ->
    +        Changeset.after_action(changeset, fn _changeset, user ->
    +          link_identity(user, payload, context)
    +        end)
    +
    +      :error ->
    +        changeset
    +    end
    +  end
    +
    +  defp link_identity(user, payload, context) do
    +    with {:ok, oauth_strategy} <-
    +           Info.strategy(user.__struct__, String.to_existing_atom(payload["strategy"])),
    +         {:ok, _identity} <-
    +           UserIdentity.Actions.upsert(
    +             oauth_strategy.identity_resource,
    +             %{
    +               user_info: payload["user_info"],
    +               oauth_tokens: payload["oauth_tokens"],
    +               strategy: oauth_strategy.name,
    +               user_id: user.id
    +             },
    +             Ash.Context.to_opts(context)
    +           ) do
    +      {:ok, user}
    +    end
    +  end
     end
    
  • lib/ash_authentication/add_ons/confirmation.ex+31 0 modified
    @@ -173,4 +173,35 @@ defmodule AshAuthentication.AddOn.Confirmation do
           {:ok, token}
         end
       end
    +
    +  @doc """
    +  Generate a confirmation token that links an OAuth2/OIDC provider identity to an
    +  existing account once confirmed.
    +
    +  Issued for `on_untrusted_email_match :confirm`. The token is bound to the
    +  existing `user` (so the confirmation lands in their inbox and confirms against
    +  their account), while `payload` (the provider strategy name, `user_info` and
    +  `oauth_tokens`) is stored server-side and used to create the identity when the
    +  token is confirmed.
    +  """
    +  @spec confirmation_token_for_link(
    +          Confirmation.t(),
    +          Resource.record(),
    +          map,
    +          opts :: Keyword.t()
    +        ) ::
    +          {:ok, String.t()} | :error | {:error, any}
    +  def confirmation_token_for_link(strategy, user, payload, opts \\ []) do
    +    claims = %{"act" => strategy.confirm_action_name}
    +
    +    with {:ok, token, _claims} <-
    +           Jwt.token_for_user(
    +             user,
    +             claims,
    +             Keyword.merge(opts, token_lifetime: strategy.token_lifetime)
    +           ),
    +         :ok <- Confirmation.Actions.store_identity_link(strategy, token, payload, opts) do
    +      {:ok, token}
    +    end
    +  end
     end
    
  • lib/ash_authentication/errors/confirmation_required.ex+27 0 added
    @@ -0,0 +1,27 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Errors.ConfirmationRequired do
    +  @moduledoc """
    +  An OAuth2/OIDC sign-in presented an email matching an existing account, but
    +  the email could not be trusted to prove ownership.
    +
    +  Raised internally to abort the sign-in's upsert without mutating the existing
    +  account. The strategy's `on_untrusted_email_match` is `:confirm`, so the
    +  caller issues a confirmation to the existing account's email and links the
    +  provider identity only once the recipient proves ownership.
    +
    +  The `user`, `user_info` and `oauth_tokens` fields are for internal use by the
    +  caller that issues the confirmation - they are never surfaced to the end user,
    +  to avoid leaking which email addresses are registered.
    +  """
    +  use Splode.Error,
    +    fields: [:strategy, :user, :user_info, :oauth_tokens],
    +    class: :forbidden
    +
    +  @type t :: Exception.t()
    +
    +  @impl true
    +  def message(_), do: "Confirmation required"
    +end
    
  • lib/ash_authentication/strategies/apple/dsl.ex+1 0 modified
    @@ -33,6 +33,7 @@ defmodule AshAuthentication.Strategy.Apple.Dsl do
           auto_set_fields: strategy_fields(Assent.Strategy.Apple, icon: :apple),
           schema: patch_schema(secret_type)
         })
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp patch_schema(secret_type) do
    
  • lib/ash_authentication/strategies/apple/verifier.ex+4 3 modified
    @@ -12,12 +12,13 @@ defmodule AshAuthentication.Strategy.Apple.Verifier do
     
       @doc false
       @spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()}
    -  def verify(strategy, _dsl_state) do
    +  def verify(strategy, dsl_state) do
         with :ok <- validate_secret(strategy, :client_id),
              :ok <- validate_secret(strategy, :team_id),
              :ok <- validate_secret(strategy, :private_key_id),
    -         :ok <- validate_secret(strategy, :private_key_path) do
    -      validate_secret(strategy, :redirect_uri)
    +         :ok <- validate_secret(strategy, :private_key_path),
    +         :ok <- validate_secret(strategy, :redirect_uri) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
         end
       end
     end
    
  • lib/ash_authentication/strategies/auth0/dsl.ex+1 0 modified
    @@ -32,6 +32,7 @@ defmodule AshAuthentication.Strategy.Auth0.Dsl do
           auto_set_fields: [assent_strategy: Auth0, icon: :auth0]
         })
         |> Custom.set_defaults(Auth0.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/custom/verifier.ex+15 10 modified
    @@ -23,17 +23,22 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
         dsl_state
         |> Info.authentication_strategies()
         |> Stream.concat(Info.authentication_add_ons(dsl_state))
    -    |> Enum.reduce_while(:ok, fn
    -      strategy, :ok ->
    -        strategy_module = strategy_module(strategy)
    -
    -        strategy
    -        |> strategy_module.verify(dsl_state)
    -        |> case do
    -          :ok -> {:cont, :ok}
    -          {:error, reason} -> {:halt, {:error, reason}}
    -        end
    +    |> Enum.reduce_while({:ok, []}, fn strategy, {:ok, warnings} ->
    +      strategy_module = strategy_module(strategy)
    +
    +      strategy
    +      |> strategy_module.verify(dsl_state)
    +      |> case do
    +        :ok -> {:cont, {:ok, warnings}}
    +        {:warn, warning} -> {:cont, {:ok, warnings ++ List.wrap(warning)}}
    +        {:error, reason} -> {:halt, {:error, reason}}
    +      end
         end)
    +    |> case do
    +      {:ok, []} -> :ok
    +      {:ok, warnings} -> {:warn, warnings}
    +      {:error, reason} -> {:error, reason}
    +    end
       end
     
       # This is needed by some strategies which re-use another strategy's entity (ie everything based on oauth2).
    
  • lib/ash_authentication/strategies/dynamic_oidc/identity_change.ex+40 42 modified
    @@ -18,16 +18,16 @@ defmodule AshAuthentication.Strategy.DynamicOidc.IdentityChange do
     
       use Ash.Resource.Change
       alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
    -  alias AshAuthentication.{Info, Strategy, UserIdentity}
    +  alias AshAuthentication.{Info, Strategy.OAuth2, UserIdentity}
       import AshAuthentication.Utils, only: [is_falsy: 1]
     
       @doc false
       @impl true
       @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
    -  def change(changeset, _opts, _context) do
    +  def change(changeset, _opts, context) do
         case Info.strategy_for_action(changeset.resource, changeset.action.name) do
           {:ok, strategy} ->
    -        do_change(changeset, strategy)
    +        do_change(changeset, strategy, context)
     
           :error ->
             {:error,
    @@ -37,46 +37,44 @@ defmodule AshAuthentication.Strategy.DynamicOidc.IdentityChange do
         end
       end
     
    -  defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset
    +  defp do_change(changeset, strategy, _context) when is_falsy(strategy.identity_resource),
    +    do: changeset
     
    -  defp do_change(changeset, strategy) do
    -    Changeset.after_action(changeset, fn changeset, user ->
    -      with {:ok, user_id_attribute_name} <-
    -             UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    -           attrs <-
    -             %{
    -               user_info: Changeset.get_argument(changeset, :user_info),
    -               oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
    -               strategy: namespaced_strategy_name(strategy)
    -             }
    -             |> Map.put(user_id_attribute_name, user.id),
    -           {:ok, _identity} <-
    -             UserIdentity.Actions.upsert(strategy.identity_resource, attrs) do
    -        user
    -        |> Ash.load(
    -          [
    -            {strategy.identity_relationship_name,
    -             Ash.Query.new(strategy.identity_resource)
    -             |> Ash.Query.set_context(%{
    -               private: %{
    -                 ash_authentication?: true
    -               }
    -             })}
    -          ],
    -          domain: Info.domain!(strategy.resource)
    -        )
    -      else
    -        {:error, reason} -> {:error, reason}
    -      end
    -    end)
    -  end
    +  defp do_change(changeset, strategy, context) do
    +    opts = [tenant: context.tenant, actor: context.actor]
     
    -  # Namespaces the strategy name with the connection id when one is set.
    -  # Falls back to the bare strategy name (matching OAuth2 behaviour) when
    -  # called outside the dynamic_oidc plug — e.g. from a test fixture.
    -  defp namespaced_strategy_name(%{__connection_id__: nil} = strategy),
    -    do: Strategy.name(strategy) |> to_string()
    +    changeset
    +    |> Changeset.before_action(&OAuth2.UserResolver.resolve(&1, strategy, opts))
    +    |> Changeset.after_action(&upsert_identity(&1, &2, strategy, opts))
    +  end
     
    -  defp namespaced_strategy_name(%{__connection_id__: connection_id} = strategy),
    -    do: "#{Strategy.name(strategy)}/#{connection_id}"
    +  defp upsert_identity(changeset, user, strategy, opts) do
    +    with {:ok, user_id_attribute_name} <-
    +           UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    +         attrs <-
    +           %{
    +             user_info: Changeset.get_argument(changeset, :user_info),
    +             oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
    +             strategy: OAuth2.identity_strategy_name(strategy)
    +           }
    +           |> Map.put(user_id_attribute_name, user.id),
    +         {:ok, _identity} <-
    +           UserIdentity.Actions.upsert(strategy.identity_resource, attrs, opts) do
    +      user
    +      |> Ash.load(
    +        [
    +          {strategy.identity_relationship_name,
    +           Ash.Query.new(strategy.identity_resource)
    +           |> Ash.Query.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })}
    +        ],
    +        Keyword.put(opts, :domain, Info.domain!(strategy.resource))
    +      )
    +    else
    +      {:error, reason} -> {:error, reason}
    +    end
    +  end
     end
    
  • lib/ash_authentication/strategies/dynamic_oidc/verifier.ex+4 2 modified
    @@ -22,8 +22,10 @@ defmodule AshAuthentication.Strategy.DynamicOidc.Verifier do
       @spec verify(DynamicOidc.t(), map) :: :ok | {:error, Exception.t()}
       def verify(strategy, dsl_state) do
         with :ok <- validate_secret(strategy, :redirect_uri),
    -         :ok <- validate_connection_resource(strategy) do
    -      OAuth2.Verifier.prevent_hijacking(dsl_state, strategy)
    +         :ok <- validate_connection_resource(strategy),
    +         :ok <- OAuth2.Verifier.prevent_hijacking(dsl_state, strategy),
    +         :ok <- OAuth2.Verifier.validate_confirmation_for_untrusted_match(dsl_state, strategy) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
         end
       end
     
    
  • lib/ash_authentication/strategies/github/dsl.ex+1 0 modified
    @@ -32,6 +32,7 @@ defmodule AshAuthentication.Strategy.Github.Dsl do
           auto_set_fields: [icon: :github, assent_strategy: Github]
         })
         |> Custom.set_defaults(Github.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/google/dsl.ex+1 0 modified
    @@ -33,6 +33,7 @@ defmodule AshAuthentication.Strategy.Google.Dsl do
           auto_set_fields: [icon: :google, assent_strategy: Google]
         })
         |> Custom.set_defaults(Google.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
       end
     
       defp strategy_override_docs(strategy) do
    
  • lib/ash_authentication/strategies/oauth2/actions.ex+98 7 modified
    @@ -9,8 +9,21 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
       Provides the code interface for working with resources via an OAuth2 strategy.
       """
     
    -  alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource}
    -  alias AshAuthentication.{Errors, Info, Strategy.OAuth2}
    +  alias Ash.{
    +    Changeset,
    +    Error.Framework.AssumptionFailed,
    +    Error.Invalid.NoSuchAction,
    +    Query,
    +    Resource
    +  }
    +
    +  alias AshAuthentication.{
    +    AddOn.Confirmation,
    +    Errors,
    +    Errors.ConfirmationRequired,
    +    Info,
    +    Strategy.OAuth2
    +  }
     
       @doc """
       Attempt to sign in a user.
    @@ -120,11 +133,17 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
         |> Ash.create()
         |> case do
           {:error, error} ->
    -        {:error,
    -         Errors.AuthenticationFailed.exception(
    -           strategy: strategy,
    -           caused_by: error
    -         )}
    +        case find_confirmation_required(error) do
    +          {:ok, confirmation_required} ->
    +            {:error, confirmation_required(strategy, confirmation_required, options)}
    +
    +          :error ->
    +            {:error,
    +             Errors.AuthenticationFailed.exception(
    +               strategy: strategy,
    +               caused_by: error
    +             )}
    +        end
     
           other ->
             other
    @@ -139,4 +158,76 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
              action: strategy.register_action_name,
              type: :create
            )}
    +
    +  # `on_untrusted_email_match :confirm`: issue a confirmation to the existing
    +  # account's email, then surface a generic `AuthenticationFailed` carrying a
    +  # scrubbed `ConfirmationRequired` as its reason. The plug/controller can match
    +  # on that reason to tell the user to check their email, without the user
    +  # record or provider tokens riding downstream.
    +  defp confirmation_required(strategy, %ConfirmationRequired{} = confirmation_required, opts) do
    +    reason =
    +      case issue_link_confirmation(strategy, confirmation_required, opts) do
    +        :ok -> ConfirmationRequired.exception(strategy: strategy)
    +        {:error, reason} -> reason
    +      end
    +
    +    Errors.AuthenticationFailed.exception(strategy: strategy, caused_by: reason)
    +  end
    +
    +  defp issue_link_confirmation(strategy, %ConfirmationRequired{} = confirmation_required, opts) do
    +    payload = %{
    +      "strategy" => to_string(strategy.name),
    +      "user_info" => confirmation_required.user_info,
    +      "oauth_tokens" => confirmation_required.oauth_tokens
    +    }
    +
    +    with {:ok, confirmation} <- find_confirmation_add_on(strategy.resource),
    +         {:ok, token} <-
    +           Confirmation.confirmation_token_for_link(
    +             confirmation,
    +             confirmation_required.user,
    +             payload,
    +             opts
    +           ) do
    +      {sender, send_opts} = confirmation.sender
    +
    +      send_opts
    +      |> Keyword.put(:tenant, Keyword.get(opts, :tenant))
    +      |> Keyword.put(:confirmation_type, :identity_link)
    +      |> Keyword.put(:provider, strategy.name)
    +      |> then(&sender.send(confirmation_required.user, token, &1))
    +
    +      :ok
    +    end
    +  end
    +
    +  defp find_confirmation_add_on(resource) do
    +    case Enum.find(Info.authentication_add_ons(resource), &match?(%Confirmation{}, &1)) do
    +      nil ->
    +        {:error,
    +         AssumptionFailed.exception(
    +           message:
    +             "`on_untrusted_email_match :confirm` requires a confirmation add-on, but none was found"
    +         )}
    +
    +      confirmation ->
    +        {:ok, confirmation}
    +    end
    +  end
    +
    +  defp find_confirmation_required(%ConfirmationRequired{} = error), do: {:ok, error}
    +
    +  defp find_confirmation_required(%{errors: errors}) when is_list(errors),
    +    do: find_confirmation_required(errors)
    +
    +  defp find_confirmation_required(errors) when is_list(errors) do
    +    Enum.find_value(errors, :error, fn error ->
    +      case find_confirmation_required(error) do
    +        {:ok, _} = found -> found
    +        :error -> false
    +      end
    +    end)
    +  end
    +
    +  defp find_confirmation_required(_), do: :error
     end
    
  • lib/ash_authentication/strategies/oauth2/dsl.ex+13 1 modified
    @@ -156,9 +156,21 @@ defmodule AshAuthentication.Strategy.OAuth2.Dsl do
             identity_resource: [
               type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]},
               doc:
    -            "The resource used to store user identities, or `false` to disable. See the User Identities section of the strategy docs for more.",
    +            "The resource used to store user identities. Required: matching users by email or other provider claims is unsafe, so the provider's `iss`/`sub` claims must be persisted. See the User Identities section of the strategy docs for more.",
               default: false
             ],
    +        trust_email_verified?: [
    +          type: :boolean,
    +          doc:
    +            "Whether the provider's `email_verified` claim can be trusted to attach an OAuth2 sign-in to a pre-existing local account with the same email. Only enable this for providers that reliably assert email ownership. When `false`, a sign-in whose `iss`/`sub` is not yet known will never be matched to an existing account by email.",
    +          default: false
    +        ],
    +        on_untrusted_email_match: [
    +          type: {:one_of, [:reject, :confirm]},
    +          doc:
    +            "What to do when a new `iss`/`sub` presents an email matching an existing account but the email can't be trusted (see `trust_email_verified?`). `:reject` (the default) refuses the sign-in. `:confirm` issues a confirmation to the existing account's email and links the provider only once the recipient proves ownership; requires a `confirmation` add-on. Note: confirming binds whatever provider identity initiated the flow, so the confirmation email must make clear which provider is being linked - otherwise a user can be tricked into linking an attacker's provider account.",
    +          default: :reject
    +        ],
             identity_relationship_name: [
               type: :atom,
               doc: "Name of the relationship to the provider identities resource",
    
  • lib/ash_authentication/strategies/oauth2.ex+47 0 modified
    @@ -239,6 +239,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
         identity_resource: false,
         name: nil,
         nonce: false,
    +    on_untrusted_email_match: :reject,
         prevent_hijacking?: true,
         openid_configuration_uri: nil,
         openid_configuration: nil,
    @@ -255,6 +256,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
         strategy_module: __MODULE__,
         team_id: nil,
         token_url: nil,
    +    trust_email_verified?: false,
         trusted_audiences: nil,
         user_url: nil,
         code_verifier: false,
    @@ -296,6 +298,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
               name: atom,
               prevent_hijacking?: boolean,
               nonce: boolean | secret,
    +          on_untrusted_email_match: :reject | :confirm,
               openid_configuration_uri: nil | binary,
               openid_configuration: nil | map,
               private_key: secret,
    @@ -311,6 +314,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do
               strategy_module: module,
               team_id: secret,
               token_url: secret,
    +          trust_email_verified?: boolean,
               trusted_audiences: secret_list,
               user_url: secret,
               code_verifier: secret,
    @@ -321,4 +325,47 @@ defmodule AshAuthentication.Strategy.OAuth2 do
       defdelegate dsl, to: Dsl
       defdelegate transform(strategy, dsl_state), to: Transformer
       defdelegate verify(strategy, dsl_state), to: Verifier
    +
    +  @uid_keys ["uid", "sub", "id", :uid, :sub, :id]
    +
    +  @doc """
    +  Extract the unique provider identifier (the OpenID Connect `sub` claim) from a
    +  provider's `user_info` map.
    +
    +  `uid` is the AshAuthentication convention, `sub` is the OpenID Connect claim,
    +  and `id` is what some providers (eg Google in the past) have returned. The
    +  same extraction must be used both when looking a user up by their identity and
    +  when persisting the identity, so this is the single source of truth.
    +  """
    +  @spec uid_from_user_info(map) :: String.t() | nil
    +  def uid_from_user_info(user_info) do
    +    user_info
    +    |> Map.take(@uid_keys)
    +    |> Map.values()
    +    |> Enum.reject(&is_nil/1)
    +    |> List.first()
    +    |> case do
    +      nil -> nil
    +      uid -> to_string(uid)
    +    end
    +  end
    +
    +  @doc """
    +  The value stored in (and matched against) the user identity resource's
    +  `strategy` field for the given runtime strategy.
    +
    +  For most strategies this is the bare strategy name. For `dynamic_oidc` - whose
    +  connection configuration is data-driven per tenant/customer - it is namespaced
    +  with the matched connection id (`"<name>/<connection_id>"`) so that IdPs which
    +  may issue colliding `sub` claims are disambiguated. This is the single source
    +  of truth shared by the identity write (`IdentityChange`) and read
    +  (`UserResolver`) paths.
    +  """
    +  @spec identity_strategy_name(t | map) :: String.t()
    +  def identity_strategy_name(strategy) do
    +    case Map.get(strategy, :__connection_id__) do
    +      nil -> to_string(strategy.name)
    +      connection_id -> "#{strategy.name}/#{connection_id}"
    +    end
    +  end
     end
    
  • lib/ash_authentication/strategies/oauth2/identity_change.ex+48 30 modified
    @@ -4,21 +4,30 @@
     
     defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
       @moduledoc """
    -  Updates the identity resource when a user is registered.
    +  Resolves and updates the user's identity when registering via OAuth2/OIDC.
    +
    +  Runs in two phases:
    +
    +    * `before_action` - resolves *which* local user this sign-in belongs to,
    +      using the provider's `iss`/`sub` (never the email). See
    +      `AshAuthentication.Strategy.OAuth2.UserResolver` for the matching rules.
    +    * `after_action` - upserts the identity row for the resolved user so that
    +      future sign-ins with the same `iss`/`sub` resolve to them.
       """
     
       use Ash.Resource.Change
       alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change}
    -  alias AshAuthentication.{Info, Strategy, UserIdentity}
    +  alias AshAuthentication.{Info, Strategy, Strategy.OAuth2, UserIdentity}
       import AshAuthentication.Utils, only: [is_falsy: 1]
    +  require Ash.Query
     
       @doc false
       @impl true
       @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
    -  def change(changeset, _opts, _context) do
    +  def change(changeset, _opts, context) do
         case Info.strategy_for_action(changeset.resource, changeset.action.name) do
           {:ok, strategy} ->
    -        do_change(changeset, strategy)
    +        do_change(changeset, strategy, context)
     
           :error ->
             {:error,
    @@ -28,37 +37,46 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do
         end
       end
     
    -  defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset
    +  defp do_change(changeset, strategy, _context) when is_falsy(strategy.identity_resource),
    +    do: changeset
    +
    +  defp do_change(changeset, strategy, context) do
    +    opts = [tenant: context.tenant, actor: context.actor]
     
    -  # sobelow_skip ["DOS.BinToAtom"]
    -  defp do_change(changeset, strategy) do
         changeset
    -    |> Changeset.after_action(fn changeset, user ->
    -      with {:ok, user_id_attribute_name} <-
    -             UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    -           {:ok, _identity} <-
    -             UserIdentity.Actions.upsert(strategy.identity_resource, %{
    +    |> Changeset.before_action(&OAuth2.UserResolver.resolve(&1, strategy, opts))
    +    |> Changeset.after_action(&upsert_identity(&1, &2, strategy, opts))
    +  end
    +
    +  defp upsert_identity(changeset, user, strategy, opts) do
    +    with {:ok, user_id_attribute_name} <-
    +           UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource),
    +         {:ok, _identity} <-
    +           UserIdentity.Actions.upsert(
    +             strategy.identity_resource,
    +             %{
                    user_info: Changeset.get_argument(changeset, :user_info),
                    oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens),
                    strategy: Strategy.name(strategy),
                    "#{user_id_attribute_name}": user.id
    -             }) do
    -        user
    -        |> Ash.load(
    -          [
    -            {strategy.identity_relationship_name,
    -             Ash.Query.new(strategy.identity_resource)
    -             |> Ash.Query.set_context(%{
    -               private: %{
    -                 ash_authentication?: true
    -               }
    -             })}
    -          ],
    -          domain: Info.domain!(strategy.resource)
    -        )
    -      else
    -        {:error, reason} -> {:error, reason}
    -      end
    -    end)
    +             },
    +             opts
    +           ) do
    +      user
    +      |> Ash.load(
    +        [
    +          {strategy.identity_relationship_name,
    +           Ash.Query.new(strategy.identity_resource)
    +           |> Ash.Query.set_context(%{
    +             private: %{
    +               ash_authentication?: true
    +             }
    +           })}
    +        ],
    +        Keyword.put(opts, :domain, Info.domain!(strategy.resource))
    +      )
    +    else
    +      {:error, reason} -> {:error, reason}
    +    end
       end
     end
    
  • lib/ash_authentication/strategies/oauth2/sign_in_preparation.ex+97 12 modified
    @@ -15,7 +15,16 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
       """
       use Ash.Resource.Preparation
       alias Ash.{Query, Resource.Preparation}
    -  alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt, UserIdentity}
    +
    +  alias AshAuthentication.{
    +    Errors.AuthenticationFailed,
    +    Info,
    +    Jwt,
    +    Strategy.OAuth2,
    +    Strategy.OAuth2.UserResolver,
    +    UserIdentity
    +  }
    +
       import AshAuthentication.Utils, only: [is_falsy: 1]
     
       @doc false
    @@ -41,7 +50,10 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
       end
     
       defp handle_sign_in_result(query, [user], strategy, context) do
    -    with {:ok, user} <- maybe_update_identity(user, query, strategy),
    +    opts = [tenant: context.tenant, actor: context.actor]
    +
    +    with :ok <- verify_identity(user, query, strategy, opts),
    +         {:ok, user} <- maybe_update_identity(user, query, strategy, opts),
              extra_claims = query.context[:extra_token_claims] || %{},
              {:ok, user} <- maybe_generate_token(user, extra_claims, context) do
           {:ok, [user]}
    @@ -62,17 +74,90 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
          )}
       end
     
    -  defp maybe_update_identity(user, _query, strategy) when is_falsy(strategy.identity_resource),
    -    do: {:ok, user}
    +  defp verify_identity(_user, _query, strategy, _opts) when is_falsy(strategy.identity_resource),
    +    do: :ok
    +
    +  defp verify_identity(user, query, strategy, opts) do
    +    user_info = Query.get_argument(query, :user_info)
    +
    +    case OAuth2.uid_from_user_info(user_info) do
    +      nil ->
    +        identity_error(query, strategy, "Provider did not return a stable `sub`/`uid` claim")
    +
    +      uid ->
    +        verify_resolved_identity(user, query, strategy, user_info, uid, opts)
    +    end
    +  end
    +
    +  defp verify_resolved_identity(user, query, strategy, user_info, uid, opts) do
    +    case UserResolver.fetch_identity(strategy, uid, opts) do
    +      {:ok, identity} ->
    +        if identity_belongs_to?(identity, user, strategy) do
    +          :ok
    +        else
    +          identity_error(query, strategy, "Identity is linked to a different user")
    +        end
    +
    +      :error ->
    +        cond do
    +          UserResolver.has_identity_for_strategy?(strategy, user, opts) ->
    +            identity_error(
    +              query,
    +              strategy,
    +              "A different #{strategy.name} identity is already linked to this account"
    +            )
    +
    +          UserResolver.email_trusted?(strategy, user_info) ->
    +            :ok
    +
    +          true ->
    +            identity_error(
    +              query,
    +              strategy,
    +              "Email could not be verified and an account with this email already exists"
    +            )
    +        end
    +    end
    +  end
    +
    +  defp identity_belongs_to?(identity, user, strategy) do
    +    {:ok, user_id_attribute_name} =
    +      UserIdentity.Info.user_identity_user_id_attribute_name(strategy.identity_resource)
    +
    +    [pk] = Ash.Resource.Info.primary_key(strategy.resource)
    +
    +    Map.get(identity, user_id_attribute_name) == Map.get(user, pk)
    +  end
    +
    +  defp identity_error(query, strategy, message) do
    +    {:error,
    +     AuthenticationFailed.exception(
    +       strategy: strategy,
    +       query: query,
    +       caused_by: %{
    +         module: __MODULE__,
    +         action: query.action,
    +         strategy: strategy,
    +         message: message
    +       }
    +     )}
    +  end
    +
    +  defp maybe_update_identity(user, _query, strategy, _opts)
    +       when is_falsy(strategy.identity_resource),
    +       do: {:ok, user}
     
    -  defp maybe_update_identity(user, query, strategy) do
    +  defp maybe_update_identity(user, query, strategy, opts) do
         strategy.identity_resource
    -    |> UserIdentity.Actions.upsert(%{
    -      user_info: Query.get_argument(query, :user_info),
    -      oauth_tokens: Query.get_argument(query, :oauth_tokens),
    -      strategy: strategy.name,
    -      user_id: user.id
    -    })
    +    |> UserIdentity.Actions.upsert(
    +      %{
    +        user_info: Query.get_argument(query, :user_info),
    +        oauth_tokens: Query.get_argument(query, :oauth_tokens),
    +        strategy: strategy.name,
    +        user_id: user.id
    +      },
    +      opts
    +    )
         |> case do
           {:ok, _identity} ->
             user
    @@ -86,7 +171,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
                    }
                  })}
               ],
    -          domain: Info.domain!(strategy.resource)
    +          Keyword.put(opts, :domain, Info.domain!(strategy.resource))
             )
     
           {:error, reason} ->
    
  • lib/ash_authentication/strategies/oauth2/user_resolver.ex+248 0 added
    @@ -0,0 +1,248 @@
    +# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
    +#
    +# SPDX-License-Identifier: MIT
    +
    +defmodule AshAuthentication.Strategy.OAuth2.UserResolver do
    +  @moduledoc """
    +  Resolves which local user an OAuth2/OIDC sign-in belongs to.
    +
    +  Per OpenID Connect Core only the `iss`/`sub` claim combination uniquely and
    +  stably identifies an end-user, so matching is driven by the user identity
    +  resource - **never** by the email address.
    +
    +  Given the changeset for an OAuth2/OIDC register (upsert) action, the rules are:
    +
    +    1. If an identity already exists for this `(strategy, sub)`, the sign-in
    +       belongs to that user. The changeset's upsert keys are rewritten to that
    +       user's values so the upsert resolves to them (and the provider cannot
    +       change a user's email).
    +
    +    2. Otherwise (a `sub` not seen before):
    +       * If no local account has the provider's email - proceed (a new account
    +         is created; if the email is not trusted and a confirmation add-on is
    +         present, that add-on gates it).
    +       * If an account with that email already has an identity for this strategy
    +         (a *different* `sub`) - reject. A single account cannot have two
    +         identities for the same provider auto-linked.
    +       * If the strategy's `email_verified` claim can be trusted
    +         (`trust_email_verified?` and the claim is true) - link the sign-in to
    +         that account.
    +       * Otherwise the email cannot be trusted to prove ownership. With
    +         `on_untrusted_email_match :reject` (the default) the sign-in is
    +         rejected and the user must sign in with their existing method to link
    +         the provider. With `on_untrusted_email_match :confirm` the upsert is
    +         aborted with a `ConfirmationRequired` error so the caller can issue a
    +         confirmation to the existing account's email and link the provider
    +         only once the recipient proves ownership.
    +
    +  Rejections are surfaced as a generic `AuthenticationFailed` error to avoid
    +  leaking which email addresses are registered.
    +  """
    +
    +  alias Ash.Changeset
    +
    +  alias AshAuthentication.{
    +    Errors.AuthenticationFailed,
    +    Errors.ConfirmationRequired,
    +    Info,
    +    Strategy.OAuth2,
    +    UserIdentity
    +  }
    +
    +  require Ash.Query
    +
    +  @doc false
    +  @spec resolve(Changeset.t(), OAuth2.t(), keyword) :: Changeset.t()
    +  def resolve(changeset, strategy, opts \\ []) do
    +    user_info = Changeset.get_argument(changeset, :user_info)
    +
    +    case OAuth2.uid_from_user_info(user_info) do
    +      nil ->
    +        reject(changeset, strategy, "Provider did not return a stable `sub`/`uid` claim")
    +
    +      uid ->
    +        case fetch_identity(strategy, uid, opts) do
    +          {:ok, identity} -> coerce_to_existing_user(changeset, strategy, identity, opts)
    +          :error -> resolve_new_identity(changeset, strategy, user_info, opts)
    +        end
    +    end
    +  end
    +
    +  defp resolve_new_identity(changeset, strategy, user_info, opts) do
    +    case fetch_user_by_upsert_identity(changeset, strategy, opts) do
    +      :error ->
    +        # No local account has this email - allow the upsert to create one.
    +        changeset
    +
    +      {:ok, user} ->
    +        cond do
    +          has_identity_for_strategy?(strategy, user, opts) ->
    +            reject(
    +              changeset,
    +              strategy,
    +              "A different #{strategy.name} identity is already linked to this account"
    +            )
    +
    +          email_trusted?(strategy, user_info) ->
    +            # Verified email matches an existing account - link to it. The email
    +            # already matches so the upsert resolves to this user.
    +            changeset
    +
    +          strategy.on_untrusted_email_match == :confirm ->
    +            require_confirmation(changeset, strategy, user, user_info)
    +
    +          true ->
    +            reject(
    +              changeset,
    +              strategy,
    +              "Email could not be verified and an account with this email already exists"
    +            )
    +        end
    +    end
    +  end
    +
    +  defp require_confirmation(changeset, strategy, user, user_info) do
    +    # Abort the upsert without touching the existing account. The caller issues
    +    # a confirmation to the existing account's email and links the provider only
    +    # once the recipient proves ownership.
    +    Changeset.add_error(
    +      changeset,
    +      ConfirmationRequired.exception(
    +        strategy: strategy,
    +        user: user,
    +        user_info: user_info,
    +        oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens)
    +      )
    +    )
    +  end
    +
    +  defp coerce_to_existing_user(changeset, strategy, identity, opts) do
    +    case load_identity_user(strategy, identity, opts) do
    +      {:ok, user} ->
    +        Enum.reduce(upsert_identity_keys(changeset), changeset, fn key, changeset ->
    +          Changeset.force_change_attribute(changeset, key, Map.get(user, key))
    +        end)
    +
    +      :error ->
    +        # Orphaned identity (user no longer exists) - fall back to the upsert.
    +        changeset
    +    end
    +  end
    +
    +  @doc false
    +  @spec fetch_identity(OAuth2.t(), String.t(), keyword) :: {:ok, Ash.Resource.record()} | :error
    +  def fetch_identity(strategy, uid, opts \\ []) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +
    +    strategy.identity_resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([
    +      {cfg.strategy_attribute_name, OAuth2.identity_strategy_name(strategy)},
    +      {cfg.uid_attribute_name, uid}
    +    ])
    +    |> read_one(identity_domain(strategy), opts)
    +  end
    +
    +  defp load_identity_user(strategy, identity, opts) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +    user_id = Map.get(identity, cfg.user_id_attribute_name)
    +
    +    strategy.resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([{user_pk(strategy), user_id}])
    +    |> read_one(Info.domain!(strategy.resource), opts)
    +  end
    +
    +  defp fetch_user_by_upsert_identity(changeset, strategy, opts) do
    +    keys = upsert_identity_keys(changeset)
    +    values = Enum.map(keys, &{&1, Changeset.get_attribute(changeset, &1)})
    +
    +    if keys == [] or Enum.any?(values, fn {_key, value} -> is_nil(value) end) do
    +      :error
    +    else
    +      strategy.resource
    +      |> base_query(opts)
    +      |> Ash.Query.do_filter(values)
    +      |> read_one(Info.domain!(strategy.resource), opts)
    +    end
    +  end
    +
    +  @doc false
    +  @spec has_identity_for_strategy?(OAuth2.t(), Ash.Resource.record(), keyword) :: boolean
    +  def has_identity_for_strategy?(strategy, user, opts \\ []) do
    +    cfg = UserIdentity.Info.user_identity_options(strategy.identity_resource)
    +
    +    strategy.identity_resource
    +    |> base_query(opts)
    +    |> Ash.Query.do_filter([
    +      {cfg.strategy_attribute_name, OAuth2.identity_strategy_name(strategy)},
    +      {cfg.user_id_attribute_name, Map.get(user, user_pk(strategy))}
    +    ])
    +    |> read_one(identity_domain(strategy), opts)
    +    |> case do
    +      {:ok, _identity} -> true
    +      :error -> false
    +    end
    +  end
    +
    +  @doc false
    +  @spec email_trusted?(OAuth2.t(), map) :: boolean
    +  def email_trusted?(%{trust_email_verified?: true}, user_info) do
    +    Map.get(user_info, "email_verified", Map.get(user_info, :email_verified)) in [true, "true"]
    +  end
    +
    +  def email_trusted?(_strategy, _user_info), do: false
    +
    +  defp upsert_identity_keys(changeset) do
    +    with name when not is_nil(name) <- changeset.action.upsert_identity,
    +         identity when not is_nil(identity) <-
    +           Ash.Resource.Info.identity(changeset.resource, name) do
    +      identity.keys
    +    else
    +      _ -> []
    +    end
    +  end
    +
    +  defp user_pk(strategy) do
    +    [pk] = Ash.Resource.Info.primary_key(strategy.resource)
    +    pk
    +  end
    +
    +  defp identity_domain(strategy) do
    +    {:ok, domain} = UserIdentity.Info.user_identity_domain(strategy.identity_resource)
    +    domain
    +  end
    +
    +  defp base_query(resource, opts) do
    +    resource
    +    |> Ash.Query.new()
    +    |> Ash.Query.set_context(%{private: %{ash_authentication?: true}})
    +    |> maybe_set_tenant(opts[:tenant])
    +  end
    +
    +  defp maybe_set_tenant(query, nil), do: query
    +  defp maybe_set_tenant(query, tenant), do: Ash.Query.set_tenant(query, tenant)
    +
    +  defp read_one(query, domain, opts) do
    +    case Ash.read(query, domain: domain, actor: opts[:actor]) do
    +      {:ok, [record | _]} -> {:ok, record}
    +      _ -> :error
    +    end
    +  end
    +
    +  defp reject(changeset, strategy, message) do
    +    Changeset.add_error(
    +      changeset,
    +      AuthenticationFailed.exception(
    +        strategy: strategy,
    +        changeset: changeset,
    +        caused_by: %{
    +          module: __MODULE__,
    +          strategy: strategy,
    +          action: changeset.action.name,
    +          message: message
    +        }
    +      )
    +    )
    +  end
    +end
    
  • lib/ash_authentication/strategies/oauth2/verifier.ex+39 6 modified
    @@ -21,12 +21,45 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do
              :ok <- validate_secret(strategy, :base_url),
              :ok <- validate_secret(strategy, :token_url),
              :ok <- validate_secret(strategy, :user_url),
    -         :ok <- prevent_hijacking(dsl_state, strategy) do
    -      if strategy.auth_method == :private_key_jwt do
    -        validate_secret(strategy, :private_key)
    -      else
    -        :ok
    -      end
    +         :ok <- prevent_hijacking(dsl_state, strategy),
    +         :ok <- validate_confirmation_for_untrusted_match(dsl_state, strategy),
    +         :ok <- validate_private_key(strategy) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
    +    end
    +  end
    +
    +  defp validate_private_key(%{auth_method: :private_key_jwt} = strategy),
    +    do: validate_secret(strategy, :private_key)
    +
    +  defp validate_private_key(_strategy), do: :ok
    +
    +  @doc """
    +  Verifies that a strategy using `on_untrusted_email_match :confirm` also has a
    +  confirmation add-on, which is required to issue and apply the link.
    +  """
    +  @spec validate_confirmation_for_untrusted_match(map, OAuth2.t()) ::
    +          :ok | {:error, Exception.t()}
    +  def validate_confirmation_for_untrusted_match(_dsl_state, %{on_untrusted_email_match: :reject}),
    +    do: :ok
    +
    +  def validate_confirmation_for_untrusted_match(dsl_state, strategy) do
    +    if Enum.any?(
    +         AshAuthentication.Info.authentication_add_ons(dsl_state),
    +         &(&1.__struct__ == AshAuthentication.AddOn.Confirmation)
    +       ) do
    +      :ok
    +    else
    +      {:error,
    +       DslError.exception(
    +         path: [:authentication, :strategies, strategy.name],
    +         message: """
    +         `on_untrusted_email_match` is set to `:confirm`, but no `confirmation` add-on is configured.
    +
    +         Linking a provider via confirmation requires a confirmation add-on to issue the confirmation
    +         and apply the link once the recipient proves ownership. Add a `confirmation` add-on, or set
    +         `on_untrusted_email_match :reject`.
    +         """
    +       )}
         end
       end
     
    
  • lib/ash_authentication/strategies/oidc/verifier.ex+8 6 modified
    @@ -17,12 +17,14 @@ defmodule AshAuthentication.Strategy.Oidc.Verifier do
              :ok <- validate_secret(strategy, :client_secret, [nil]),
              :ok <- validate_secret(strategy, :base_url),
              :ok <- validate_secret(strategy, :nonce, [true, false]),
    -         :ok <- OAuth2.Verifier.prevent_hijacking(dsl_state, strategy) do
    -      if strategy.auth_method == :private_key_jwt do
    -        validate_secret(strategy, :private_key)
    -      else
    -        :ok
    -      end
    +         :ok <- OAuth2.Verifier.prevent_hijacking(dsl_state, strategy),
    +         :ok <- validate_private_key(strategy) do
    +      oauth2_strategy_warnings(strategy, dsl_state)
         end
       end
    +
    +  defp validate_private_key(%{auth_method: :private_key_jwt} = strategy),
    +    do: validate_secret(strategy, :private_key)
    +
    +  defp validate_private_key(_strategy), do: :ok
     end
    
  • lib/ash_authentication/strategies/slack/dsl.ex+1 0 modified
    @@ -34,6 +34,7 @@ defmodule AshAuthentication.Strategy.Slack.Dsl do
           auto_set_fields: [icon: :slack, assent_strategy: Slack]
         })
         |> Custom.set_defaults(Slack.default_config([]))
    +    |> Custom.set_defaults(trust_email_verified?: true)
         |> Map.update!(
           :schema,
           fn schema ->
    
  • lib/ash_authentication/user_identity/actions.ex+5 3 modified
    @@ -17,8 +17,8 @@ defmodule AshAuthentication.UserIdentity.Actions do
       @doc """
       Upsert an identity for a user.
       """
    -  @spec upsert(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term}
    -  def upsert(resource, attributes) do
    +  @spec upsert(Resource.t(), map, keyword) :: {:ok, Resource.record()} | {:error, term}
    +  def upsert(resource, attributes, opts \\ []) do
         with {:ok, domain} <- UserIdentity.Info.user_identity_domain(resource),
              {:ok, upsert_action_name} <-
                UserIdentity.Info.user_identity_upsert_action_name(resource),
    @@ -32,7 +32,9 @@ defmodule AshAuthentication.UserIdentity.Actions do
           })
           |> Changeset.for_create(upsert_action_name, attributes,
             upsert?: true,
    -        upsert_identity: action.upsert_identity
    +        upsert_identity: action.upsert_identity,
    +        tenant: opts[:tenant],
    +        actor: opts[:actor]
           )
           |> Ash.create(domain: domain)
         end
    
  • lib/ash_authentication/user_identity/transformer.ex+15 5 modified
    @@ -51,7 +51,7 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, user_id} <-
    +         {:ok, _user_id} <-
                UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state),
              {:ok, access_token} <-
                UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
    @@ -69,9 +69,9 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, dsl_state} <-
                maybe_build_attribute(dsl_state, uid, Type.String, allow_nil?: false, writable?: true),
              :ok <- validate_uid_field(dsl_state, uid),
    -         {:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, strategy]),
    +         {:ok, dsl_state} <- maybe_build_identity(dsl_state, [uid, strategy]),
              :ok <-
    -           validate_attribute_unique_constraint(dsl_state, [user_id, uid, strategy], resource),
    +           validate_attribute_unique_constraint(dsl_state, [uid, strategy], resource),
              {:ok, dsl_state} <-
                maybe_build_attribute(dsl_state, access_token, Type.String,
                  allow_nil?: true,
    @@ -226,7 +226,13 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, identity} <- find_identity(dsl_state, [user_id, uid, strategy]),
    +         {:ok, identity} <- find_identity(dsl_state, [uid, strategy]),
    +         {:ok, access_token} <-
    +           UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state),
    +         {:ok, access_token_expires_at} <-
    +           UserIdentity.Info.user_identity_access_token_expires_at_attribute_name(dsl_state),
    +         {:ok, refresh_token} <-
    +           UserIdentity.Info.user_identity_refresh_token_attribute_name(dsl_state),
              {:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state),
              {:ok, user_resource_id} <- find_pk(user_resource) do
           arguments = [
    @@ -257,6 +263,10 @@ defmodule AshAuthentication.UserIdentity.Transformer do
             name: action_name,
             upsert?: true,
             upsert_identity: identity.name,
    +        # Only refresh the tokens on conflict. The `user_id` binding is set once,
    +        # on insert, and is never re-pointed - a provider identity belongs to
    +        # exactly one local user, permanently.
    +        upsert_fields: [access_token, access_token_expires_at, refresh_token],
             arguments: arguments,
             changes: changes,
             accept: [strategy]
    @@ -282,7 +292,7 @@ defmodule AshAuthentication.UserIdentity.Transformer do
              {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state),
              {:ok, strategy} <-
                UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state),
    -         {:ok, identity} <- find_identity(dsl_state, [uid, user_id, strategy]),
    +         {:ok, identity} <- find_identity(dsl_state, [uid, strategy]),
              :ok <- validate_field_in_values(action, :upsert_identity, [identity.name]) do
           :ok
         else
    
  • lib/ash_authentication/user_identity/upsert_identity_change.ex+2 10 modified
    @@ -21,6 +21,7 @@ defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
     
       use Ash.Resource.Change
       alias Ash.{Changeset, Resource.Change}
    +  alias AshAuthentication.Strategy.OAuth2
       alias AshAuthentication.UserIdentity.Info
     
       @doc false
    @@ -33,16 +34,7 @@ defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do
         oauth_tokens = Changeset.get_argument(changeset, :oauth_tokens)
         user_id = Changeset.get_argument(changeset, cfg.user_id_attribute_name)
     
    -    uid =
    -      user_info
    -      # uid is a convention
    -      # sub is supposedly from the spec
    -      # id is from what has been seen from Google
    -      |> Map.take(["uid", "sub", "id", :uid, :sub, :id])
    -      |> Map.values()
    -      |> Enum.reject(&is_nil/1)
    -      |> List.first()
    -      |> to_string()
    +    uid = OAuth2.uid_from_user_info(user_info)
     
         changeset
         |> Changeset.change_attribute(cfg.user_id_attribute_name, user_id)
    
  • lib/ash_authentication/validations.ex+69 0 modified
    @@ -241,4 +241,73 @@ defmodule AshAuthentication.Validations do
           :ok
         end
       end
    +
    +  @doc """
    +  Collect compile-time warnings for an OAuth2/OIDC strategy.
    +
    +  Returns `{:warn, messages}` (so the configuration still compiles) for the
    +  following safety issues:
    +
    +    * No `identity_resource` is configured. Matching a local user by their email
    +      address (or any other provider-supplied claim) is not safe: per the
    +      OpenID Connect Core specification only the `iss`/`sub` claims uniquely and
    +      stably identify an end-user, and the identity resource is where those are
    +      persisted. This will become a hard requirement in a future release.
    +
    +    * The provider's `email_verified` claim is not trusted
    +      (`trust_email_verified?` is `false`) and no confirmation add-on is
    +      configured. Accounts created via this strategy would carry an unverified
    +      email address with no way to verify ownership.
    +  """
    +  @spec oauth2_strategy_warnings(struct, Dsl.t() | map) :: :ok | {:warn, [String.t()]}
    +  def oauth2_strategy_warnings(strategy, dsl_state) do
    +    [
    +      identity_resource_warning(strategy),
    +      email_verification_warning(strategy, dsl_state)
    +    ]
    +    |> Enum.reject(&is_nil/1)
    +    |> case do
    +      [] -> :ok
    +      warnings -> {:warn, warnings}
    +    end
    +  end
    +
    +  defp identity_resource_warning(%{identity_resource: identity_resource} = strategy)
    +       when is_falsy(identity_resource) do
    +    """
    +    The `#{inspect(strategy.name)}` strategy on `#{inspect(strategy.resource)}` has no `identity_resource` configured.
    +
    +    OAuth2 and OIDC strategies should store the provider's `iss`/`sub` claims in
    +    a user identity resource. Matching a local user by their email address (or
    +    any other provider claim) is unsafe - only the `iss`/`sub` combination
    +    uniquely and stably identifies an end-user. This will become a hard
    +    requirement in a future release.
    +
    +    Run `mix ash_authentication.upgrade` to generate and wire up the required
    +    resource, or see the "User Identities" section of the strategy documentation.
    +    """
    +  end
    +
    +  defp identity_resource_warning(_strategy), do: nil
    +
    +  defp email_verification_warning(%{trust_email_verified?: false} = strategy, dsl_state) do
    +    unless has_confirmation_add_on?(dsl_state) do
    +      """
    +      The `#{inspect(strategy.name)}` strategy on `#{inspect(strategy.resource)}` does not trust the provider's `email_verified` claim (`trust_email_verified?` is `false`) and no confirmation add-on is configured.
    +
    +      Accounts created via this strategy will carry an unverified email address
    +      and there is no way to verify ownership. Add a confirmation add-on that
    +      monitors the email field, or - only for providers that reliably assert
    +      email ownership - set `trust_email_verified? true`.
    +      """
    +    end
    +  end
    +
    +  defp email_verification_warning(_strategy, _dsl_state), do: nil
    +
    +  defp has_confirmation_add_on?(dsl_state) do
    +    dsl_state
    +    |> AshAuthentication.Info.authentication_add_ons()
    +    |> Enum.any?(&(&1.__struct__ == AshAuthentication.AddOn.Confirmation))
    +  end
     end
    
  • priv/repo/migrations/20260608014212_require_user_identity_unique_key.exs+53 0 added
    @@ -0,0 +1,53 @@
    +defmodule Example.Repo.Migrations.RequireUserIdentityUniqueKey do
    +  @moduledoc """
    +  Updates resources based on their most recent snapshots.
    +
    +  This file was autogenerated with `mix ash_postgres.generate_migrations`
    +  """
    +
    +  use Ecto.Migration
    +
    +  def up do
    +    drop_if_exists(
    +      unique_index(:user_identities, [:strategy, :uid, :user_id],
    +        name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +      )
    +    )
    +
    +    create unique_index(:user_identities, [:strategy, :uid],
    +             name: "user_identities_unique_on_strategy_and_uid_index"
    +           )
    +
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:strategy, :uid, :user_id],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:strategy, :uid],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +           )
    +  end
    +
    +  def down do
    +    drop_if_exists(
    +      unique_index(:mt_user_identities, [:strategy, :uid],
    +        name: "mt_user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:mt_user_identities, [:strategy, :uid, :user_id],
    +             name: "mt_user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +           )
    +
    +    drop_if_exists(
    +      unique_index(:user_identities, [:strategy, :uid],
    +        name: "user_identities_unique_on_strategy_and_uid_index"
    +      )
    +    )
    +
    +    create unique_index(:user_identities, [:strategy, :uid, :user_id],
    +             name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
    +           )
    +  end
    +end
    
  • priv/resource_snapshots/repo/mt_user_identities/20260608014213.json+173 0 added
    @@ -0,0 +1,173 @@
    +{
    +  "attributes": [
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "refresh_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token_expires_at",
    +      "type": "utc_datetime_usec"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "uid",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "strategy",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "fragment(\"gen_random_uuid()\")",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": true,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "organisation_id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_user_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_user"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "user_id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": "id",
    +          "global": true,
    +          "strategy": "attribute"
    +        },
    +        "name": "mt_user_identities_organisation_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "mt_organisations"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "organisation_id",
    +      "type": "uuid"
    +    }
    +  ],
    +  "base_filter": null,
    +  "check_constraints": [],
    +  "create_table_options": null,
    +  "custom_indexes": [],
    +  "custom_statements": [],
    +  "has_create_action": true,
    +  "hash": "CE07A5C06EE9D91123FF3F28EE6AB65BB177B0AD02DEA62762C4973F7E21A97C",
    +  "identities": [
    +    {
    +      "all_tenants?": false,
    +      "base_filter": null,
    +      "index_name": "mt_user_identities_unique_on_strategy_and_uid_index",
    +      "keys": [
    +        {
    +          "type": "atom",
    +          "value": "strategy"
    +        },
    +        {
    +          "type": "atom",
    +          "value": "uid"
    +        }
    +      ],
    +      "name": "unique_on_strategy_and_uid",
    +      "nils_distinct?": true,
    +      "where": null
    +    }
    +  ],
    +  "multitenancy": {
    +    "attribute": null,
    +    "global": null,
    +    "strategy": null
    +  },
    +  "repo": "Elixir.Example.Repo",
    +  "schema": null,
    +  "table": "mt_user_identities"
    +}
    \ No newline at end of file
    
  • priv/resource_snapshots/repo/user_identities/20260608014214.json+142 0 added
    @@ -0,0 +1,142 @@
    +{
    +  "attributes": [
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "refresh_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token_expires_at",
    +      "type": "utc_datetime_usec"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "access_token",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "uid",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "strategy",
    +      "type": "text"
    +    },
    +    {
    +      "allow_nil?": false,
    +      "default": "fragment(\"gen_random_uuid()\")",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": true,
    +      "references": null,
    +      "scale": null,
    +      "size": null,
    +      "source": "id",
    +      "type": "uuid"
    +    },
    +    {
    +      "allow_nil?": true,
    +      "default": "nil",
    +      "generated?": false,
    +      "precision": null,
    +      "primary_key?": false,
    +      "references": {
    +        "deferrable": false,
    +        "destination_attribute": "id",
    +        "destination_attribute_default": null,
    +        "destination_attribute_generated": null,
    +        "index?": false,
    +        "match_type": null,
    +        "match_with": null,
    +        "multitenancy": {
    +          "attribute": null,
    +          "global": null,
    +          "strategy": null
    +        },
    +        "name": "user_identities_user_id_fkey",
    +        "on_delete": null,
    +        "on_update": null,
    +        "primary_key?": true,
    +        "schema": "public",
    +        "table": "user"
    +      },
    +      "scale": null,
    +      "size": null,
    +      "source": "user_id",
    +      "type": "uuid"
    +    }
    +  ],
    +  "base_filter": null,
    +  "check_constraints": [],
    +  "create_table_options": null,
    +  "custom_indexes": [],
    +  "custom_statements": [],
    +  "has_create_action": true,
    +  "hash": "CCBEBD88EAD865E14B3B83D345B31E3D1D09AAC7846505550C58EA65D93DEB49",
    +  "identities": [
    +    {
    +      "all_tenants?": false,
    +      "base_filter": null,
    +      "index_name": "user_identities_unique_on_strategy_and_uid_index",
    +      "keys": [
    +        {
    +          "type": "atom",
    +          "value": "strategy"
    +        },
    +        {
    +          "type": "atom",
    +          "value": "uid"
    +        }
    +      ],
    +      "name": "unique_on_strategy_and_uid",
    +      "nils_distinct?": true,
    +      "where": null
    +    }
    +  ],
    +  "multitenancy": {
    +    "attribute": null,
    +    "global": null,
    +    "strategy": null
    +  },
    +  "repo": "Elixir.Example.Repo",
    +  "schema": null,
    +  "table": "user_identities"
    +}
    \ No newline at end of file
    

Vulnerability mechanics

Root cause

"The OAuth2/OIDC sign-in flow matched local users by email address (via upsert or user-defined sign-in filter) instead of by the OpenID Connect iss/sub claim combination, allowing a provider login presenting a victim's email to take over their account."

Attack vector

An unauthenticated attacker who can register an account on any accepted OAuth provider using the victim's email address (or who benefits from provider-side email reuse or reclamation) can sign in as that victim. Because the old code matched users by email rather than by the OpenID Connect `iss`/`sub` claim combination [ref_id=1], a provider login presenting a victim's email — even an unverified or reused email — resolved to and signed in as the victim's existing local account, giving the attacker full local privileges.

Affected code

The vulnerability resides in the OAuth2/OIDC sign-in flow of `ash_authentication`. The core defect is that `AshAuthentication.Strategy.OAuth2.UserResolver` (introduced in both patches) replaces the previous logic that matched local users by email address (via an upsert on the email field or a user-defined sign-in filter). The patch also touches `SignInPreparation` and `Actions` modules to verify the resolved identity against the `(strategy, sub)` pair stored in the `UserIdentity` resource.

What the fix does

The patches introduce a new `UserResolver` module that resolves users by the `(strategy, sub)` identity stored in a user identity resource, never by email [patch_id=6057618][patch_id=6057619]. A new `sub` whose email matches an existing account is only auto-linked when the provider's `email_verified` claim is trusted (`trust_email_verified?`); otherwise the sign-in is rejected or a confirmation is sent to the existing account's email. The `identity_resource` is now required, and the sign-in preparation step verifies that the resolved identity belongs to the user before completing the sign-in.

Preconditions

  • configThe targeted application must use AshAuthentication's OAuth2 or OIDC strategies to allow sign-in via an external provider.
  • inputThe attacker must be able to register an account (or obtain an account) on an accepted OAuth/OIDC provider using the victim's email address.
  • inputNo prior identity record linking the victim's local account to the attacker's provider sub must exist (the attacker is using a sub not yet seen by the application).

Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.