Upgrading to version 5.0.0

Version 5.0.0 includes several breaking changes related to action types and the Google OAuth strategy. Most changes can be handled automatically using the upgrade task.

Dependencies

This version requires Assent ~> 0.3.0 (updated from ~> 0.2.9) and adds nimble_totp as a dependency for the new TOTP strategy. Run mix deps.get after updating your ash_authentication version.

Automated Upgrade

If you have Igniter installed, run:

mix igniter.upgrade ash_authentication 4.x.x 5.0.0

Replace 4.x.x with your current version. This will automatically:

  • Convert token resource revoked? actions from :read to :action type
  • Convert password reset and magic link request actions from :read to :action type
  • Create get_by_<identity_field> read actions where needed
  • Update "google_hd" references to "hd" in your codebase
  • Add identity-keyed brute-force protection to password and magic link strategies, generating an audit_log add-on (and audit log resource) if one is not already present

Breaking Changes

1. Sender failures now propagate as errors

Previously, sender failures were silently ignored. Now, senders that return {:error, reason} will cause the authentication action to fail with an AshAuthentication.Errors.SenderFailed error.

What this affects:

  • Password reset requests
  • Magic link requests
  • Confirmation emails

Action required: Review your sender implementations. If they can return {:error, reason}, ensure your application handles these failures appropriately. Senders returning :ok or {:ok, result} (common with mailer libraries) will continue to work unchanged.

Recommended approach: Consider using a durable background job library like Oban for sending authentication emails. This provides automatic retries, failure tracking, and prevents transient email delivery issues from blocking user authentication flows. Your sender can enqueue a job and return :ok immediately, while the actual email delivery happens asynchronously with built-in resilience.

defmodule MyApp.AuthEmailSender do
  use AshAuthentication.Sender

  def send(user_or_email, token, opts) do
    %{user_or_email: user_or_email, token: token, opts: opts}
    |> MyApp.Workers.AuthEmail.new()
    |> Oban.insert()

    :ok
  end
end

2. Request actions converted to generic actions

Password reset request (request_password_reset_with_password) and magic link request (request_magic_link) actions are now generated as :action type instead of :read.

Action required: If you have customised these actions as :read actions, the upgrader will convert them automatically. If you've made extensive customisations, review the converted code to ensure it still meets your requirements.

The new actions work with auto-generated get_by_<identity_field> read actions for user lookup.

3. Token revoked action converted to generic action

The revoked? action on token resources is now a generic action returning a boolean, rather than a read action returning a record.

Action required: If you have a custom revoked? read action on your token resource, the upgrader will convert it automatically.

4. Google strategy now uses OIDC

The Google OAuth strategy now uses OIDC (via Assent 0.3.0) instead of the legacy API. This changes two fields in the user_info map:

OldNew
user_info["google_hd"]user_info["hd"]
user_info["email_verified"] (string "true")user_info["email_verified"] (boolean true)

Action required:

  1. The upgrader will automatically rename "google_hd" to "hd" in your code
  2. You must manually update any checks for email_verified:
# Before
user_info["email_verified"] == "true"

# After
user_info["email_verified"] == true

5. dynamic_oidc identities are namespaced by connection

A dynamic_oidc strategy serves many IdP connections. Each connection now gets its own identity namespace in the identity resource's strategy field: "<strategy_name>/<connection_id>" instead of the bare "<strategy_name>". Two IdPs that issue the same sub claim therefore stay apart.

Identity rows written by an earlier version carry the bare name. The lookup no longer matches them. Until you relink them, an existing user who signs in through dynamic_oidc is refused, or - if your register action has no upsert_identity, or its key no longer matches - gets a second, empty account.

dynamic_oidc is the only strategy affected. Every other OAuth2 and OIDC strategy keeps the bare strategy name, so its rows are unchanged.

Action required, if you run dynamic_oidc. Move each existing row into its connection's namespace. Do not delete the rows: a deleted row is not recreated on the next sign-in, and deleting it discards the stored refresh token.

Run this in the same deploy as the upgrade, before anyone signs in. A user who signs in first gets a namespaced row, which can then collide with the bare row on the (uid, strategy) unique index.

If your connection resource holds exactly one row, the mapping is unambiguous:

-- Check first. This procedure is only sound when the count is 1.
SELECT count(*) FROM oidc_connections;

UPDATE user_identities
   SET strategy = 'sso/' || '<the one connection id>'
 WHERE strategy = 'sso';

Replace user_identities, oidc_connections, sso and the connection id with your own. If your identity resource is multitenant and each tenant holds one connection, run the same statement per tenant.

If you run more than one connection, read this before you upgrade. Nothing on an identity row records which connection wrote it, so the rows cannot be sorted automatically. Two further consequences:

  1. Where two connections issued the same sub, the two users were resolved to one account. The second user's row was never written, so no code change can separate them. The surviving account may also hold the other user's email or name, because the register upsert wrote them.
  2. You cannot find those cases from your own tables. One sub produced one row, and it looks like any other row. Export each connection's sub set from the IdP and intersect the sets. A non-empty intersection is a pair of users that were already merged.

6. OAuth2 sign-in strategies must name the attribute that holds the email

A sign-in-only OAuth2 strategy attaches a new provider identity to the account its read action matched. Before this version it attached whenever trust_email_verified? was set and the provider sent email_verified. It never compared the email itself. An action filtered on a username therefore attached the sign-in to an account the signer-in did not own.

The rule now requires the provider's verified email to equal the account's own email. The new email_field option names the attribute that holds that email. It defaults to :email, and every strategy built on oauth2 inherits it.

This configuration no longer compiles:

attributes do
  uuid_primary_key :id
  attribute :username, :ci_string, allow_nil?: false, public?: true
end

authentication do
  strategies do
    github do
      # ...
      registration_enabled? false
    end
  end
end
authentication -> strategies -> github -> email_field :
  `email_field` is set to `:email`, which is not an attribute of this resource.

Three things must be true together before the error appears:

  1. The strategy trusts the provider's claim (trust_email_verified? true). The apple, auth0, github, google and slack strategies set this by default.
  2. The strategy is sign-in only (registration_enabled? false).
  3. email_field names no attribute of the resource.

A register strategy is never checked. It compares the verified email against the upsert_identity values that matched the account, so it needs no named attribute.

Action required: choose one of two resolutions.

  • The resource holds the email under another name. Set email_field to that attribute:

    github do
      email_field :email_address
      registration_enabled? false
    end
  • The resource stores no email address. Set trust_email_verified? false:

    github do
      trust_email_verified? false
      registration_enabled? false
    end

The configurations that now fail to compile are the ones that were silently attaching sign-ins to accounts the signer-in did not own. The compile error is the point of the change, not a cost of it.

An action filtered on the email is unaffected. Its matched value is the provider's email, so it attaches exactly as before.

Also note: a sign-in that no longer attaches has no linking path. on_untrusted_email_match :confirm is read by the register action only. The person must sign in with their existing method to link the provider.

Accounts already linked under the old rule stay linked. This change prevents new links. It does not unpick existing ones. Review your UserIdentity rows for links whose provider email does not match the linked account's email.

Igniter Task Changes

Phoenix-specific code moved to ash_authentication_phoenix

The igniter tasks for password, magic_link, TOTP, and confirmation strategies no longer generate Phoenix-specific code (Swoosh senders with verified routes, controller modifications). AA's tasks now generate only resource-level code with basic IO.puts senders.

If you use ash_authentication_phoenix, its new convention-named strategy tasks (ash_authentication_phoenix.add_strategy.password, etc.) handle the Phoenix integration automatically — upgrading senders to use Swoosh and verified routes, modifying the AuthController for TOTP 2FA, and inserting TOTP routes.

Action required: If you re-run the igniter installer, the Phoenix-specific sender code will now come from AAP's tasks. Existing senders in your project are not affected — the igniter uses on_exists: :warning for sender modules, so existing customisations are preserved.

New Features

TOTP Two-Factor Authentication

Version 5.0.0 adds a complete TOTP (Time-based One-Time Password) strategy for two-factor authentication. See the TOTP tutorial for setup instructions.

To add TOTP to an existing project with ash_authentication_phoenix:

mix ash_authentication_phoenix.add_strategy totp

This composes both the AA resource-level task and the AAP Phoenix integration task.

Extra JWT Claims

You can now add custom claims to JWT tokens using the extra_claims option in the tokens DSL section, or dynamically via AshAuthentication.add_token_claims/2. See the tokens guide for details.

Identity-keyed brute-force protection

The password and magic_link strategies now support a brute_force_strategy option that counts recent failures by identity (via the audit log add-on) and blocks further attempts when the configured threshold is reached. The upgrader enables this automatically by:

  • Ensuring an audit_log add-on (and audit log resource) is present on each resource using password or magic_link.
  • Adding brute_force_strategy {:audit_log, <name>} to each affected strategy block.

Defaults to a 5 minute window with 5 allowed failures; tune audit_log_window and audit_log_max_failures on each strategy if you need different thresholds.

Sign in with Apple and other response_mode=form_post providers

OAuth2/OIDC callbacks now accept POST as well as GET. This supports providers that use response_mode=form_post — most importantly Sign in with Apple, which returns the callback as a POST whenever name or email scope is requested.

That callback is a cross-site POST from the provider, so the browser does not send the SameSite=Lax session cookie with it — which is why the stored state/nonce/PKCE verifier couldn't be read and sign-in failed. To handle this without weakening your session cookie to SameSite=None, the callback now renders a small "signing you in…" interstitial that immediately re-POSTs the parameters same-origin (kept in the request body, never the URL), where the session cookie is sent and sign-in completes. The page fails closed if it can't recover the session, and is a no-op for providers that use a normal GET callback.

Because the callback is a cross-site POST, Phoenix's :protect_from_forgery (which has no CSRF token to check) would otherwise reject it — so it must be skipped for that request (OAuth state is the CSRF defence). ash_authentication_phoenix provides a :skip_csrf_for_oauth_callback plug that skips CSRF only for POSTs to callback paths; every other request (including all other auth POSTs) stays fully protected.

Using ash_authentication_phoenix: when your resources define an OAuth/OIDC strategy, the installer and upgrader do two things automatically:

  1. Generate an editable interstitial — a signing_in.html.eex template, an AuthInterstitialHTML renderer module, and the :oauth2_interstitial_renderer config. Edit signing_in.html.eex to customise copy/branding.
  2. Prepend plug :skip_csrf_for_oauth_callback to your :browser pipeline (before :protect_from_forgery).

Both happen when you add a provider via mix ash_authentication_phoenix.add_strategy apple (or google, github, oidc, …), and the upgrader applies them to existing OAuth apps.

Hand-rolled routers / non-Phoenix apps: no code change is required to route the callback (it already accepts both methods), and the built-in default interstitial renders with zero configuration. Keep your session cookie on the default SameSite=Lax, but:

  • Ensure the callback POST is exempt from CSRF. With ash_authentication_phoenix, add its plug before :protect_from_forgery:

    pipeline :browser do
      # ...
      plug :skip_csrf_for_oauth_callback
      plug :protect_from_forgery
    end
  • To customise the interstitial page, set:

    config :ash_authentication, :oauth2_interstitial_renderer, {MyApp.SomeModule, :render}

    where the function takes the render assigns (:action, :params, :reflected_param) and returns the HTML.

Other Improvements

  • Auto signout in AshAuthentication.Phoenix - Automatic sign-out is now supported in the Phoenix integration
  • API key header prefix regex support - The ApiKey.Plug now accepts regex patterns for header prefix matching
  • Better error handling - Jwt.token_for_user/2 now returns {:error, AuthenticationFailed.t} on failure instead of raising

Upgrading to version 4.0.0

Version 4.0.0 of AshAuthentication adds support for Ash 3.0 and in line with a number of changes in Ash there are some corresponding changes to Ash Authentication:

  • Token generation is enabled by default, meaning that you will have to explicitly set authentication.tokens.enabled? to false if you don't need them.

  • Sign in tokens are enabled by default in the password strategy. What this means is that instead of returning a regular user token on sign-in in the user's metadata, we generate a short-lived token which can be used to actually sign the user in. This is specifically to allow live-view based sign-in UIs to display an authentication error without requiring a page-load.

Upgrading to version 3.6.0.

As of version 3.6.0 the TokenResource extension adds the subject attribute which allows us to more easily match tokens to specific users. This unlocks some new use-cases (eg sign out everywhere).

This means that you will need to generate new migrations and migrate your database.

Upgrade steps:

Warning

If you already have tokens stored in your database then the migration will likely throw a migration error due to the new NOT NULL constraint on subject. If this happens then you can either delete all your tokens or explicitly add the subject attribute to your resource with allow_nil? set to true. eg:

attributes do
  attribute :subject, :string, allow_nil?: true
end
  1. Run mix ash_postgres.generate_migrations --name=add_subject_to_token_resource
  2. Run mix ash_postgres.migrate
  3. 🎉