Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AshAuthenticationEdlink

Hex version badge License badge

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 — an oauth2-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.

What it handles for you

Ed.link is a near-standard OAuth2 provider with two quirks this package absorbs:

  • The $data envelope. Ed.link wraps its token and profile responses in a top-level {"$data": …} object. The strategy unwraps it, so the profile's id/email arrive flat where Ash Authentication expects them.
  • No OpenID Connect. Ed.link issues no discovery document, id_token, or nonce. Identity is taken from the profile endpoint, keyed on the stable Ed.link person id.

Installation

Add ash_authentication_edlink to your deps in mix.exs:

def deps do
  [
    {:ash_authentication_edlink, "~> 0.1"}
  ]
end

Usage

Add 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
end

Because it is built on the oauth2 strategy, every oauth2 option is available here too. See the DSL reference.

IdP-initiated login

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.

Compatibility {: .warning}

idp_initiated_login? is accepted by the DSL, but the request-phase restart it triggers lives in ash_authentication's oauth2 plug. 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 requires ash_authentication ~> 5.0.0-rc.12 or later.

Resolving a launch before you have a session (a custom plug)

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
end

Treat 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 code is 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 new state and a new code), so the code consumed here is not needed again.

See AshAuthentication.Strategy.Edlink.config_for/1 for the config it returns.

Identity

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.

Using the Assent strategy directly

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)

License

MIT. See LICENSE.

About

An Ed.link (ed.link) authentication strategy for Ash Authentication and Assent.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages