···12121313- Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the
1414 naming of `from_conn/1`.
1515+- `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling
1616+ error situations internally. Applications should implement `Plug.ErrorHandler`
1717+ to catch and gracefully handle them.
15181619### Added
1720···2225- `Atex.OAuth.Permission` module for creating
2326 [AT Protocol permission](https://atproto.com/specs/permission) strings for
2427 OAuth.
2828+- `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a
2929+ human-readable `message` string and a machine-readable `reason` atom for error
3030+ handling.
25312632### Changed
2733
+29
examples/oauth.ex
···11defmodule ExampleOAuthPlug do
22 require Logger
33 use Plug.Router
44+ use Plug.ErrorHandler
45 alias Atex.OAuth
56 alias Atex.XRPC
67···7980 # Don't use this in production
8081 "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
8182 )
8383+ end
8484+8585+ # Error handler for OAuth exceptions
8686+ @impl Plug.ErrorHandler
8787+ def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
8888+ status =
8989+ case error.reason do
9090+ reason
9191+ when reason in [
9292+ :missing_handle,
9393+ :invalid_handle,
9494+ :invalid_callback_request,
9595+ :issuer_mismatch
9696+ ] ->
9797+ 400
9898+9999+ _ ->
100100+ 500
101101+ end
102102+103103+ conn
104104+ |> put_resp_content_type("text/plain")
105105+ |> send_resp(status, error.message)
106106+ end
107107+108108+ # Fallback for other errors
109109+ def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
110110+ send_resp(conn, conn.status, "Something went wrong")
82111 end
83112end
+24
lib/atex/oauth/error.ex
···11+defmodule Atex.OAuth.Error do
22+ @moduledoc """
33+ Exception raised by `Atex.OAuth.Plug` when errors occurred. When using the
44+ Plug, you should set up a `Plug.ErrorHandler` to gracefully catch these and
55+ give messages to the end user.
66+77+ This extesion has two fields: a human-readable `message` string, and an atom
88+ `reason` for each specific error.
99+1010+ ## Reasons
1111+1212+ - `:missing_handle` - The handle query parameter was not provided
1313+ - `:invalid_handle` - The provided handle could not be resolved
1414+ - `:authorization_url_failed` - Failed to create the authorization URL
1515+ - `:invalid_callback_request` - Missing or invalid state/code in callback
1616+ - `:authorization_server_metadata_failed` - Could not fetch authorization
1717+ server metadata
1818+ - `:token_validation_failed` - Failed to validate the authorization code or
1919+ token
2020+ - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server
2121+ """
2222+2323+ defexception [:message, :reason]
2424+end
+108-75
lib/atex/oauth/plug.ex
···4455 This module provides three endpoints:
6677- - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
88- a given handle
77+ - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a
88+ given handle
99 - `GET /callback` - Handles the OAuth callback after user authorization
1010 - `GET /client-metadata.json` - Serves the OAuth client metadata
1111···1515 `secret_key_base` to have been set on your connections. Ideally it should be
1616 routed to via `Plug.Router.forward/2`, under a route like "/oauth".
17171818- The plug requires a `:callback` option that must be an MFA tuple (Module, Function, Args).
1919- This callback is invoked after successful OAuth authentication, receiving the connection
2020- with the authenticated session data.
1818+ The plug requires a `:callback` option that must be an MFA tuple (Module,
1919+ Function, Args). This callback is invoked after successful OAuth
2020+ authentication, receiving the connection with the authenticated session data.
2121+2222+ ## Error Handling
2323+2424+ `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth
2525+ flow (e.g. an invalid handle is provided, or validation failed). You should
2626+ implement a `Plug.ErrorHandler` to catch and handle these exceptions
2727+ gracefully.
21282229 ## Example
23302431 Example implementation showing how to set up the OAuth plug with proper
2525- session handling and a callback function:
3232+ session handling, error handling, and a callback function.
26332734 defmodule ExampleOAuthPlug do
2835 use Plug.Router
3636+ use Plug.ErrorHandler
29373038 plug :put_secret_key_base
3139···5361 "very long key base with at least 64 bytes"
5462 )
5563 end
6464+6565+ # Error handler for OAuth exceptions
6666+ @impl Plug.ErrorHandler
6767+ def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
6868+ status = case error.reason do
6969+ reason when reason in [:missing_handle, :invalid_handle, :invalid_callback_request, :issuer_mismatch] -> 400
7070+ _ -> 500
7171+ end
7272+7373+ conn
7474+ |> put_resp_content_type("text/plain")
7575+ |> send_resp(status, error.message)
7676+ end
7777+7878+ # Fallback for other errors
7979+ def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
8080+ send_resp(conn, conn.status, "Something went wrong")
8181+ end
5682 end
57835884 ## Session Storage
···96122 handle = conn.query_params["handle"]
9712398124 if !handle do
9999- send_resp(conn, 400, "Need `handle` query parameter")
100100- else
101101- case IdentityResolver.resolve(handle) do
102102- {:ok, identity} ->
103103- pds = DIDDocument.get_pds_endpoint(identity.document)
104104- {:ok, authz_server} = OAuth.get_authorization_server(pds)
105105- {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
106106- state = OAuth.create_nonce()
107107- code_verifier = OAuth.create_nonce()
125125+ raise Atex.OAuth.Error,
126126+ message: "Handle query parameter is required",
127127+ reason: :missing_handle
128128+ end
108129109109- case OAuth.create_authorization_url(
110110- authz_metadata,
111111- state,
112112- code_verifier,
113113- handle
114114- ) do
115115- {:ok, authz_url} ->
116116- conn
117117- |> put_resp_cookie("state", state, @oauth_cookie_opts)
118118- |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
119119- |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
120120- |> put_resp_header("location", authz_url)
121121- |> send_resp(307, "")
130130+ case IdentityResolver.resolve(handle) do
131131+ {:ok, identity} ->
132132+ pds = DIDDocument.get_pds_endpoint(identity.document)
133133+ {:ok, authz_server} = OAuth.get_authorization_server(pds)
134134+ {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
135135+ state = OAuth.create_nonce()
136136+ code_verifier = OAuth.create_nonce()
122137123123- err ->
124124- Logger.error("failed to reate authorization url, #{inspect(err)}")
125125- send_resp(conn, 500, "Internal server error")
126126- end
138138+ case OAuth.create_authorization_url(
139139+ authz_metadata,
140140+ state,
141141+ code_verifier,
142142+ handle
143143+ ) do
144144+ {:ok, authz_url} ->
145145+ conn
146146+ |> put_resp_cookie("state", state, @oauth_cookie_opts)
147147+ |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
148148+ |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
149149+ |> put_resp_header("location", authz_url)
150150+ |> send_resp(307, "")
151151+152152+ {:error, _err} ->
153153+ raise Atex.OAuth.Error,
154154+ message: "Failed to create authorization URL",
155155+ reason: :authorization_url_failed
156156+ end
127157128128- {:error, err} ->
129129- Logger.error("Failed to resolve handle, #{inspect(err)}")
130130- send_resp(conn, 400, "Invalid handle")
131131- end
158158+ _err ->
159159+ raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle
132160 end
133161 end
134162···151179152180 if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
153181 stored_state != state do
154154- send_resp(conn, 400, "Invalid request")
182182+ raise Atex.OAuth.Error,
183183+ message: "Invalid callback request: missing or mismatched state/code parameters",
184184+ reason: :invalid_callback_request
185185+ end
186186+187187+ with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
188188+ dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
189189+ {:ok, tokens, nonce} <-
190190+ OAuth.validate_authorization_code(
191191+ authz_metadata,
192192+ dpop_key,
193193+ code,
194194+ stored_code_verifier
195195+ ),
196196+ {:ok, identity} <- IdentityResolver.resolve(tokens.did),
197197+ # Make sure pds' issuer matches the stored one (just in case)
198198+ pds <- DIDDocument.get_pds_endpoint(identity.document),
199199+ {:ok, authz_server} <- OAuth.get_authorization_server(pds),
200200+ true <- authz_server == stored_issuer do
201201+ conn =
202202+ conn
203203+ |> delete_resp_cookie("state", @oauth_cookie_opts)
204204+ |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
205205+ |> delete_resp_cookie("issuer", @oauth_cookie_opts)
206206+ |> put_session(:atex_oauth, %{
207207+ access_token: tokens.access_token,
208208+ refresh_token: tokens.refresh_token,
209209+ did: tokens.did,
210210+ pds: pds,
211211+ expires_at: tokens.expires_at,
212212+ dpop_nonce: nonce,
213213+ dpop_key: dpop_key
214214+ })
215215+216216+ {mod, func, args} = callback
217217+ apply(mod, func, [conn | args])
155218 else
156156- with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
157157- dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
158158- {:ok, tokens, nonce} <-
159159- OAuth.validate_authorization_code(
160160- authz_metadata,
161161- dpop_key,
162162- code,
163163- stored_code_verifier
164164- ),
165165- {:ok, identity} <- IdentityResolver.resolve(tokens.did),
166166- # Make sure pds' issuer matches the stored one (just in case)
167167- pds <- DIDDocument.get_pds_endpoint(identity.document),
168168- {:ok, authz_server} <- OAuth.get_authorization_server(pds),
169169- true <- authz_server == stored_issuer do
170170- conn =
171171- conn
172172- |> delete_resp_cookie("state", @oauth_cookie_opts)
173173- |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
174174- |> delete_resp_cookie("issuer", @oauth_cookie_opts)
175175- |> put_session(:atex_oauth, %{
176176- access_token: tokens.access_token,
177177- refresh_token: tokens.refresh_token,
178178- did: tokens.did,
179179- pds: pds,
180180- expires_at: tokens.expires_at,
181181- dpop_nonce: nonce,
182182- dpop_key: dpop_key
183183- })
219219+ false ->
220220+ raise Atex.OAuth.Error,
221221+ message: "OAuth issuer does not match PDS' authorization server",
222222+ reason: :issuer_mismatch
184223185185- {mod, func, args} = callback
186186- apply(mod, func, [conn | args])
187187- else
188188- false ->
189189- send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
190190-191191- err ->
192192- Logger.error("failed to validate oauth callback: #{inspect(err)}")
193193- send_resp(conn, 500, "Internal server error")
194194- end
224224+ _err ->
225225+ raise Atex.OAuth.Error,
226226+ message: "Failed to validate authorization code or token",
227227+ reason: :token_validation_failed
195228 end
196229 end
197230end