···1213- Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the
14 naming of `from_conn/1`.
0001516### Added
17···22- `Atex.OAuth.Permission` module for creating
23 [AT Protocol permission](https://atproto.com/specs/permission) strings for
24 OAuth.
0002526### Changed
27
···1213- Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the
14 naming of `from_conn/1`.
15+- `Atex.OAuth.Plug` now raises `Atex.OAuth.Error` exceptions instead of handling
16+ error situations internally. Applications should implement `Plug.ErrorHandler`
17+ to catch and gracefully handle them.
1819### Added
20···25- `Atex.OAuth.Permission` module for creating
26 [AT Protocol permission](https://atproto.com/specs/permission) strings for
27 OAuth.
28+- `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a
29+ human-readable `message` string and a machine-readable `reason` atom for error
30+ handling.
3132### Changed
33
+29
examples/oauth.ex
···1defmodule ExampleOAuthPlug do
2 require Logger
3 use Plug.Router
04 alias Atex.OAuth
5 alias Atex.XRPC
6···79 # Don't use this in production
80 "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
81 )
000000000000000000000000000082 end
83end
···1defmodule ExampleOAuthPlug do
2 require Logger
3 use Plug.Router
4+ use Plug.ErrorHandler
5 alias Atex.OAuth
6 alias Atex.XRPC
7···80 # Don't use this in production
81 "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46"
82 )
83+ end
84+85+ # Error handler for OAuth exceptions
86+ @impl Plug.ErrorHandler
87+ def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
88+ status =
89+ case error.reason do
90+ reason
91+ when reason in [
92+ :missing_handle,
93+ :invalid_handle,
94+ :invalid_callback_request,
95+ :issuer_mismatch
96+ ] ->
97+ 400
98+99+ _ ->
100+ 500
101+ end
102+103+ conn
104+ |> put_resp_content_type("text/plain")
105+ |> send_resp(status, error.message)
106+ end
107+108+ # Fallback for other errors
109+ def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
110+ send_resp(conn, conn.status, "Something went wrong")
111 end
112end
+24
lib/atex/oauth/error.ex
···000000000000000000000000
···1+defmodule Atex.OAuth.Error do
2+ @moduledoc """
3+ Exception raised by `Atex.OAuth.Plug` when errors occurred. When using the
4+ Plug, you should set up a `Plug.ErrorHandler` to gracefully catch these and
5+ give messages to the end user.
6+7+ This extesion has two fields: a human-readable `message` string, and an atom
8+ `reason` for each specific error.
9+10+ ## Reasons
11+12+ - `:missing_handle` - The handle query parameter was not provided
13+ - `:invalid_handle` - The provided handle could not be resolved
14+ - `:authorization_url_failed` - Failed to create the authorization URL
15+ - `:invalid_callback_request` - Missing or invalid state/code in callback
16+ - `:authorization_server_metadata_failed` - Could not fetch authorization
17+ server metadata
18+ - `:token_validation_failed` - Failed to validate the authorization code or
19+ token
20+ - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server
21+ """
22+23+ defexception [:message, :reason]
24+end
+108-75
lib/atex/oauth/plug.ex
···45 This module provides three endpoints:
67- - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for
8- a given handle
9 - `GET /callback` - Handles the OAuth callback after user authorization
10 - `GET /client-metadata.json` - Serves the OAuth client metadata
11···15 `secret_key_base` to have been set on your connections. Ideally it should be
16 routed to via `Plug.Router.forward/2`, under a route like "/oauth".
1718- The plug requires a `:callback` option that must be an MFA tuple (Module, Function, Args).
19- This callback is invoked after successful OAuth authentication, receiving the connection
20- with the authenticated session data.
00000002122 ## Example
2324 Example implementation showing how to set up the OAuth plug with proper
25- session handling and a callback function:
2627 defmodule ExampleOAuthPlug do
28 use Plug.Router
02930 plug :put_secret_key_base
31···53 "very long key base with at least 64 bytes"
54 )
55 end
00000000000000000056 end
5758 ## Session Storage
···96 handle = conn.query_params["handle"]
9798 if !handle do
99- send_resp(conn, 400, "Need `handle` query parameter")
100- else
101- case IdentityResolver.resolve(handle) do
102- {:ok, identity} ->
103- pds = DIDDocument.get_pds_endpoint(identity.document)
104- {:ok, authz_server} = OAuth.get_authorization_server(pds)
105- {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
106- state = OAuth.create_nonce()
107- code_verifier = OAuth.create_nonce()
108109- case OAuth.create_authorization_url(
110- authz_metadata,
111- state,
112- code_verifier,
113- handle
114- ) do
115- {:ok, authz_url} ->
116- conn
117- |> put_resp_cookie("state", state, @oauth_cookie_opts)
118- |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
119- |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
120- |> put_resp_header("location", authz_url)
121- |> send_resp(307, "")
122123- err ->
124- Logger.error("failed to reate authorization url, #{inspect(err)}")
125- send_resp(conn, 500, "Internal server error")
126- end
000000000000000127128- {:error, err} ->
129- Logger.error("Failed to resolve handle, #{inspect(err)}")
130- send_resp(conn, 400, "Invalid handle")
131- end
132 end
133 end
134···151152 if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
153 stored_state != state do
154- send_resp(conn, 400, "Invalid request")
00000000000000000000000000000000000155 else
156- with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
157- dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
158- {:ok, tokens, nonce} <-
159- OAuth.validate_authorization_code(
160- authz_metadata,
161- dpop_key,
162- code,
163- stored_code_verifier
164- ),
165- {:ok, identity} <- IdentityResolver.resolve(tokens.did),
166- # Make sure pds' issuer matches the stored one (just in case)
167- pds <- DIDDocument.get_pds_endpoint(identity.document),
168- {:ok, authz_server} <- OAuth.get_authorization_server(pds),
169- true <- authz_server == stored_issuer do
170- conn =
171- conn
172- |> delete_resp_cookie("state", @oauth_cookie_opts)
173- |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
174- |> delete_resp_cookie("issuer", @oauth_cookie_opts)
175- |> put_session(:atex_oauth, %{
176- access_token: tokens.access_token,
177- refresh_token: tokens.refresh_token,
178- did: tokens.did,
179- pds: pds,
180- expires_at: tokens.expires_at,
181- dpop_nonce: nonce,
182- dpop_key: dpop_key
183- })
184185- {mod, func, args} = callback
186- apply(mod, func, [conn | args])
187- else
188- false ->
189- send_resp(conn, 400, "OAuth issuer does not match your PDS' authorization server")
190-191- err ->
192- Logger.error("failed to validate oauth callback: #{inspect(err)}")
193- send_resp(conn, 500, "Internal server error")
194- end
195 end
196 end
197end
···45 This module provides three endpoints:
67+ - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a
8+ given handle
9 - `GET /callback` - Handles the OAuth callback after user authorization
10 - `GET /client-metadata.json` - Serves the OAuth client metadata
11···15 `secret_key_base` to have been set on your connections. Ideally it should be
16 routed to via `Plug.Router.forward/2`, under a route like "/oauth".
1718+ The plug requires a `:callback` option that must be an MFA tuple (Module,
19+ Function, Args). This callback is invoked after successful OAuth
20+ authentication, receiving the connection with the authenticated session data.
21+22+ ## Error Handling
23+24+ `Atex.OAuth.Error` exceptions are raised when errors occur during the OAuth
25+ flow (e.g. an invalid handle is provided, or validation failed). You should
26+ implement a `Plug.ErrorHandler` to catch and handle these exceptions
27+ gracefully.
2829 ## Example
3031 Example implementation showing how to set up the OAuth plug with proper
32+ session handling, error handling, and a callback function.
3334 defmodule ExampleOAuthPlug do
35 use Plug.Router
36+ use Plug.ErrorHandler
3738 plug :put_secret_key_base
39···61 "very long key base with at least 64 bytes"
62 )
63 end
64+65+ # Error handler for OAuth exceptions
66+ @impl Plug.ErrorHandler
67+ def handle_errors(conn, %{kind: :error, reason: %Atex.OAuth.Error{} = error, stack: _stack}) do
68+ status = case error.reason do
69+ reason when reason in [:missing_handle, :invalid_handle, :invalid_callback_request, :issuer_mismatch] -> 400
70+ _ -> 500
71+ end
72+73+ conn
74+ |> put_resp_content_type("text/plain")
75+ |> send_resp(status, error.message)
76+ end
77+78+ # Fallback for other errors
79+ def handle_errors(conn, %{kind: _kind, reason: _reason, stack: _stack}) do
80+ send_resp(conn, conn.status, "Something went wrong")
81+ end
82 end
8384 ## Session Storage
···122 handle = conn.query_params["handle"]
123124 if !handle do
125+ raise Atex.OAuth.Error,
126+ message: "Handle query parameter is required",
127+ reason: :missing_handle
128+ end
00000129130+ case IdentityResolver.resolve(handle) do
131+ {:ok, identity} ->
132+ pds = DIDDocument.get_pds_endpoint(identity.document)
133+ {:ok, authz_server} = OAuth.get_authorization_server(pds)
134+ {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server)
135+ state = OAuth.create_nonce()
136+ code_verifier = OAuth.create_nonce()
000000137138+ case OAuth.create_authorization_url(
139+ authz_metadata,
140+ state,
141+ code_verifier,
142+ handle
143+ ) do
144+ {:ok, authz_url} ->
145+ conn
146+ |> put_resp_cookie("state", state, @oauth_cookie_opts)
147+ |> put_resp_cookie("code_verifier", code_verifier, @oauth_cookie_opts)
148+ |> put_resp_cookie("issuer", authz_metadata.issuer, @oauth_cookie_opts)
149+ |> put_resp_header("location", authz_url)
150+ |> send_resp(307, "")
151+152+ {:error, _err} ->
153+ raise Atex.OAuth.Error,
154+ message: "Failed to create authorization URL",
155+ reason: :authorization_url_failed
156+ end
157158+ _err ->
159+ raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle
00160 end
161 end
162···179180 if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) ||
181 stored_state != state do
182+ raise Atex.OAuth.Error,
183+ message: "Invalid callback request: missing or mismatched state/code parameters",
184+ reason: :invalid_callback_request
185+ end
186+187+ with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer),
188+ dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}),
189+ {:ok, tokens, nonce} <-
190+ OAuth.validate_authorization_code(
191+ authz_metadata,
192+ dpop_key,
193+ code,
194+ stored_code_verifier
195+ ),
196+ {:ok, identity} <- IdentityResolver.resolve(tokens.did),
197+ # Make sure pds' issuer matches the stored one (just in case)
198+ pds <- DIDDocument.get_pds_endpoint(identity.document),
199+ {:ok, authz_server} <- OAuth.get_authorization_server(pds),
200+ true <- authz_server == stored_issuer do
201+ conn =
202+ conn
203+ |> delete_resp_cookie("state", @oauth_cookie_opts)
204+ |> delete_resp_cookie("code_verifier", @oauth_cookie_opts)
205+ |> delete_resp_cookie("issuer", @oauth_cookie_opts)
206+ |> put_session(:atex_oauth, %{
207+ access_token: tokens.access_token,
208+ refresh_token: tokens.refresh_token,
209+ did: tokens.did,
210+ pds: pds,
211+ expires_at: tokens.expires_at,
212+ dpop_nonce: nonce,
213+ dpop_key: dpop_key
214+ })
215+216+ {mod, func, args} = callback
217+ apply(mod, func, [conn | args])
218 else
219+ false ->
220+ raise Atex.OAuth.Error,
221+ message: "OAuth issuer does not match PDS' authorization server",
222+ reason: :issuer_mismatch
000000000000000000000000223224+ _err ->
225+ raise Atex.OAuth.Error,
226+ message: "Failed to validate authorization code or token",
227+ reason: :token_validation_failed
000000228 end
229 end
230end