An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization

refactor: throw exception in OAuth.Plug when running into problems

ovyerus.com ef9a2548 740dda29

verified
+167 -75
+6
CHANGELOG.md
··· 12 12 13 13 - Rename `Atex.XRPC.OAuthClient.update_plug/2` to `update_conn/2`, to match the 14 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. 15 18 16 19 ### Added 17 20 ··· 22 25 - `Atex.OAuth.Permission` module for creating 23 26 [AT Protocol permission](https://atproto.com/specs/permission) strings for 24 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. 25 31 26 32 ### Changed 27 33
+29
examples/oauth.ex
··· 1 1 defmodule ExampleOAuthPlug do 2 2 require Logger 3 3 use Plug.Router 4 + use Plug.ErrorHandler 4 5 alias Atex.OAuth 5 6 alias Atex.XRPC 6 7 ··· 79 80 # Don't use this in production 80 81 "5ef1078e1617463a3eb3feb9b152e76587a75a6809e0485a125b6bb7ae468f086680771f700d77ff61dfdc8d8ee8a5c7848024a41cf5ad4b6eb3115f74ce6e46" 81 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") 82 111 end 83 112 end
+24
lib/atex/oauth/error.ex
··· 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
··· 4 4 5 5 This module provides three endpoints: 6 6 7 - - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for 8 - a given handle 7 + - `GET /login?handle=<handle>` - Initiates the OAuth authorization flow for a 8 + given handle 9 9 - `GET /callback` - Handles the OAuth callback after user authorization 10 10 - `GET /client-metadata.json` - Serves the OAuth client metadata 11 11 ··· 15 15 `secret_key_base` to have been set on your connections. Ideally it should be 16 16 routed to via `Plug.Router.forward/2`, under a route like "/oauth". 17 17 18 - 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. 18 + 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. 21 28 22 29 ## Example 23 30 24 31 Example implementation showing how to set up the OAuth plug with proper 25 - session handling and a callback function: 32 + session handling, error handling, and a callback function. 26 33 27 34 defmodule ExampleOAuthPlug do 28 35 use Plug.Router 36 + use Plug.ErrorHandler 29 37 30 38 plug :put_secret_key_base 31 39 ··· 53 61 "very long key base with at least 64 bytes" 54 62 ) 55 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 56 82 end 57 83 58 84 ## Session Storage ··· 96 122 handle = conn.query_params["handle"] 97 123 98 124 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() 125 + raise Atex.OAuth.Error, 126 + message: "Handle query parameter is required", 127 + reason: :missing_handle 128 + end 108 129 109 - 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, "") 130 + 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() 122 137 123 - err -> 124 - Logger.error("failed to reate authorization url, #{inspect(err)}") 125 - send_resp(conn, 500, "Internal server error") 126 - end 138 + 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 127 157 128 - {:error, err} -> 129 - Logger.error("Failed to resolve handle, #{inspect(err)}") 130 - send_resp(conn, 400, "Invalid handle") 131 - end 158 + _err -> 159 + raise Atex.OAuth.Error, message: "Invalid or unresolvable handle", reason: :invalid_handle 132 160 end 133 161 end 134 162 ··· 151 179 152 180 if !stored_state || !stored_code_verifier || !stored_issuer || (!code || !state) || 153 181 stored_state != state do 154 - send_resp(conn, 400, "Invalid request") 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]) 155 218 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 - }) 219 + false -> 220 + raise Atex.OAuth.Error, 221 + message: "OAuth issuer does not match PDS' authorization server", 222 + reason: :issuer_mismatch 184 223 185 - {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 224 + _err -> 225 + raise Atex.OAuth.Error, 226 + message: "Failed to validate authorization code or token", 227 + reason: :token_validation_failed 195 228 end 196 229 end 197 230 end