An Ed.link authentication strategy for Ash Authentication and Assent.
Ed.link is a K-12 rostering and single-sign-on aggregator: it sits in front of district identity providers (Clever, ClassLink, Google Workspace, and others) and exposes one uniform OAuth2 + profile API. This package lets your app treat "sign in with Ed.link" like any other Ash Authentication social-login strategy.
It provides two things:
AshAuthentication.Strategy.Edlink— anoauth2-derived strategy with the Ed.link endpoints pre-filled, so you only supply your credentials.Assent.Strategy.Edlink— the underlying Assent strategy, usable on its own if you use Assent without Ash Authentication.
Ed.link is a near-standard OAuth2 provider with two quirks this package absorbs:
- The
$dataenvelope. Ed.link wraps its token and profile responses in a top-level{"$data": …}object. The strategy unwraps it, so the profile'sid/emailarrive flat where Ash Authentication expects them. - No OpenID Connect. Ed.link issues no discovery document,
id_token, ornonce. Identity is taken from the profile endpoint, keyed on the stable Ed.link personid.
Add ash_authentication_edlink to your deps in mix.exs:
def deps do
[
{:ash_authentication_edlink, "~> 0.1"}
]
endAdd the edlink strategy to your user resource's authentication block. Only
client_id, client_secret and redirect_uri are required — the Ed.link
base_url, token_url, user_url and authorize_url all default to Ed.link's
values.
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts
authentication do
strategies do
edlink do
client_id MyApp.Secrets
client_secret MyApp.Secrets
redirect_uri MyApp.Secrets
# Ed.link launches are usually IdP-initiated (a teacher clicking the
# app tile in Clever/ClassLink). See "IdP-initiated login" below.
idp_initiated_login? true
identity_resource MyApp.Accounts.UserIdentity
end
end
end
endBecause it is built on the oauth2 strategy, every oauth2 option is
available here too. See the
DSL reference.
An Ed.link launch is frequently IdP-initiated: the district user starts at
their IdP (clicking the app tile in Clever or ClassLink) rather than at your
app, and Ed.link redirects straight to your callback with a code and no
state. There is no prior request phase, so there is nothing to verify
state against.
Setting idp_initiated_login? true makes the oauth2 plug treat such a
stateless callback as a trigger to restart the request phase, redirecting
back through the authorize URL where a fresh state is minted and later
verified (OpenID Connect Core §4). This preserves CSRF protection for
IdP-initiated launches. With the flag off (the default), a stateless callback
fails closed.
idp_initiated_login?is accepted by the DSL, but the request-phase restart it triggers lives inash_authentication'soauth2plug. It is functional on releases that include that plug behaviour; on earlier releases the flag is a no-op (a stateless callback simply fails closed). This package requiresash_authentication ~> 5.0.0-rc.12or later.
Sometimes you need to know something about an IdP-initiated launch before
the real, state-verified flow runs — for example, which tenant/subdomain the
launch belongs to, so you can route the request-phase restart to the right
place. AshAuthentication.Strategy.Edlink.config_for/1 builds the Assent
config from your resolved strategy so you can drive a session-less code
exchange yourself, in a plug that runs ahead of the normal auth plug:
defmodule MyAppWeb.EdlinkLaunchRouterPlug do
@behaviour Plug
import Plug.Conn
alias AshAuthentication.{Info, Strategy.Edlink}
@impl true
def init(opts), do: opts
@impl true
def call(conn, _opts) do
conn = fetch_query_params(conn)
strategy = Info.strategy!(MyApp.Accounts.User, :edlink)
with %{"code" => code} when is_binary(code) <- conn.query_params,
{:ok, config} <- Edlink.config_for(strategy),
# This exchange posts to the callback URL Ed.link launched to — set
# redirect_uri to match it (config_for returns the configured base).
config = Keyword.put(config, :redirect_uri, current_callback_url(conn)),
{:ok, %{user: user_info}} <-
strategy.assent_strategy.callback(config, %{"code" => code}),
{:ok, target} <- route_for(user_info["id"]) do
conn |> put_resp_header("location", target) |> send_resp(302, "") |> halt()
else
_ -> conn # any miss → fall through to the normal auth flow
end
end
endTreat this as an optimisation, never a gate — three rules keep it safe:
- Read only. Mint no session, cookie, principal, or token here. The only
thing you derive from the still-unverified
codeis routing information; the real,state-verified authentication still happens in the normal flow. - Fail open. On any miss — no
code, a session already exists, the exchange fails, the lookup does not resolve — pass the conn through untouched so the normal flow (or the/sso/login-style bounce) still runs. Login must never hard-fail here. - Don't reuse the code downstream. Ed.link authorization codes are
single-use. If you exchange the code here, make sure the flow you route to
triggers a fresh authorize (e.g. an
idp_initiated_login?restart mints a newstateand a new code), so the code consumed here is not needed again.
See AshAuthentication.Strategy.Edlink.config_for/1 for the config it returns.
Ed.link issues no OIDC id_token; identity comes from the profile endpoint.
The profile's id is a stable Ed.link person identifier — use it as the uid
for your registration action / identity_resource.
If you use Assent without Ash Authentication:
config =
Assent.Strategy.Edlink.default_config([])
|> Keyword.merge(
client_id: "…",
client_secret: "…",
redirect_uri: "https://example.com/auth/edlink/callback"
)
{:ok, %{url: url, session_params: session_params}} =
Assent.Strategy.Edlink.authorize_url(config)
# …redirect the user to `url`, then on callback:
{:ok, %{user: user, token: token}} =
config
|> Keyword.put(:session_params, session_params)
|> Assent.Strategy.Edlink.callback(params)MIT. See LICENSE.