This is a quick tutorial on how to configure data-driven OpenID Connect — one
IdP per database row instead of one IdP per compile-time DSL block. It's the
building block for B2B/multi-tenant SSO: each customer brings their own Okta /
Entra ID / Auth0 / generic OIDC tenant, and you store their base_url,
client_id, and client_secret as a regular Ash resource.
If you only have a handful of IdPs known at compile time, prefer the static
oidc, okta,
auth0, or microsoft strategies instead.
Quick setup with Igniter
The fastest way to add dynamic OIDC is with the Igniter generator:
mix ash_authentication.add_strategy.dynamic_oidc
This generates an OidcConnection resource alongside your user resource, wires
a dynamic_oidc :sso strategy in, adds a register_with_sso action, and
prints follow-up instructions. The rest of this tutorial covers what's
happening — and the bits that the generator deliberately leaves to you
(multitenancy and secret encryption).
Manual setup
1. Define the connection resource
The connection resource holds one row per customer / tenant IdP. Extend it with
AshAuthentication.OidcConnection and the extension will fill in default
attributes (base_url, client_id, client_secret, display_name,
icon_url) and a default :read action.
defmodule MyApp.Accounts.OidcConnection do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication.OidcConnection],
domain: MyApp.Accounts
oidc_connection do
domain MyApp.Accounts
# All field names are configurable; defaults shown:
# base_url_field :base_url
# client_id_field :client_id
# client_secret_field :client_secret
# display_name_field :display_name
# icon_url_field :icon_url
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
postgres do
table "oidc_connections"
repo MyApp.Repo
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
# ...your own policies for admin write operations
end
endThe bypass is required: the strategy needs to read connection rows during the OIDC flow, regardless of who's signed in (or not signed in) at that moment.
2. Add the strategy
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts
authentication do
strategies do
dynamic_oidc :sso do
connection_resource MyApp.Accounts.OidcConnection
identity_resource MyApp.Accounts.UserIdentity
redirect_uri MyApp.Secrets
end
end
end
endbase_url, client_id, and client_secret are intentionally not part of
the strategy DSL — they're loaded at request time from the matched connection
row. Everything else (authorization_params, nonce,
id_token_signed_response_alg, etc.) is identical to the
oidc strategy.
identity_resource is optional but recommended once you have more than one
IdP: it lets one user link multiple identities, and
DynamicOidc.IdentityChange (see below) namespaces those identities by
connection_id so two IdPs that happen to issue the same sub claim don't
collide.
3. Define the register action
actions do
create :register_with_sso do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :unique_email
change AshAuthentication.GenerateTokenChange
# IMPORTANT: use the dynamic-aware IdentityChange, not the OAuth2 one.
# It namespaces the identity's `strategy` field with the matched
# connection_id so per-IdP identities stay distinct.
change AshAuthentication.Strategy.DynamicOidc.IdentityChange
change {AshAuthentication.Strategy.OAuth2.UserInfoToAttributes, fields: [:email]}
end
endWhy the dynamic-aware change?
OAuth2.IdentityChange writes the strategy name verbatim into the identity's
strategy field. With multiple dynamic_oidc connections that all share
one strategy name, two IdPs issuing the same sub would collide on the
{user_id, uid, strategy} unique constraint. DynamicOidc.IdentityChange
namespaces the value as "<strategy_name>/<connection_id>", keeping
per-IdP identities distinct.
If you also use the password strategy, ensure hashed_password is nullable:
attribute :hashed_password, :string, allow_nil?: true, sensitive?: truemix ash.codegen make_hashed_password_nullable
mix ash.migrate
URL shape
The strategy generates two routes:
GET /:subject/:strategy_name/:connection_id/request— initiate sign-in for a specific connection.GET /:subject/:strategy_name/callback— single shared callback URL.
For the example above (subject user, strategy sso):
GET /auth/user/sso/<connection-uuid>/request
GET /auth/user/sso/callbackEach customer's IdP admin only ever needs to register that one shared callback URL in their app integration. The connection id is remembered between request and callback in the user's session.
Multitenancy
The strategy will scope connection lookups by the current Ash tenant when your connection resource is multitenant. Make sure the tenant is set upstream of the auth router — typically in a Phoenix plug that maps subdomain or header to your tenant:
# lib/my_app_web/plugs/set_tenant_from_subdomain.ex
defmodule MyAppWeb.Plugs.SetTenantFromSubdomain do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
case conn.host |> String.split(".") do
[tenant, _domain, _tld] -> Ash.PlugHelpers.set_tenant(conn, tenant)
_ -> conn
end
end
end# lib/my_app_web/router.ex
pipeline :browser do
# ...
plug MyAppWeb.Plugs.SetTenantFromSubdomain
plug :load_from_session
endNon-multitenant connection resources are also supported — the strategy simply queries globally. That's a fine choice when you have a single deployment with multiple IdPs but no per-customer isolation.
Secret storage
Storing client_secret as a plaintext string is convenient but dangerous — a
database compromise leaks every customer's IdP credentials. Encrypt it at rest
with ash_cloak, then expose a calculation
that decrypts on load:
defmodule MyApp.Accounts.OidcConnection do
use Ash.Resource,
extensions: [AshAuthentication.OidcConnection, AshCloak],
# ...
oidc_connection do
domain MyApp.Accounts
client_secret_field :decrypted_client_secret
end
cloak do
vault MyApp.Vault
attributes [:client_secret]
decrypt_by_default [:client_secret]
end
calculations do
calculate :decrypted_client_secret, :string, expr(client_secret)
end
endAny field on the resource — attribute, calculation, or aggregate — can back any
*_field configuration option, so you have full flexibility over how the value
is sourced.
Sign-in UI (ash_authentication_phoenix)
If you're using
ash_authentication_phoenix,
the SignIn LiveView automatically renders the
AshAuthentication.Phoenix.Components.DynamicOidc component for any
dynamic_oidc strategy on your resource. It queries the connection_resource
at render time (forwarding the current Ash tenant) and renders one sign-in
button per matched row.
display_namedrives the button label, falling back to the host portion ofbase_urlif unset.icon_urldrives the icon, falling back to a generic SSO SVG if unset.- If no rows match the current tenant, no buttons are rendered — the strategy effectively goes dormant for that tenant.
That means your customer-facing UI is just: ensure
Ash.PlugHelpers.set_tenant/2 runs upstream of the LiveView mount, and the
right buttons show up.
Connection-management UI
The connection resource is just an Ash resource — actions, relationships, policies, validations all work as you'd expect. The typical pattern is:
- Admin UI for your staff: full CRUD over connections, scoped by tenant.
- Self-service UI for tenant admins: scoped CRUD where they can manage only their own tenant's IdPs.
- Validation: smoke-test a connection's
base_urlresolves anopenid-configurationdocument before letting an admin save it (callAssent.Strategy.OIDC.fetch_openid_configuration/1from a custom action).
There's nothing AshAuthentication-specific about that surface — it's the resource you defined in step 1.
More documentation
AshAuthentication.Strategy.DynamicOidc— runtime details of the strategy.AshAuthentication.OidcConnection— the resource extension used here.- Custom Strategy — if you need a different shape entirely (e.g. dynamic SAML), the dynamic_oidc strategy is itself a worked example.