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

Configure Feed

Select the types of activity you want to include in your feed.

reactor(oauth): split Atex.OAuth into several better scoped submodules

+1002 -798
+8
CHANGELOG.md
··· 11 11 - Fix `raw_input` not actually being set as the request's body in 12 12 `Atex.XRPC.post/3` when providing a struct as input. 13 13 14 + ### Breaking Changes 15 + 16 + - `Atex.OAuth.get_key/0` removed — use `Atex.Config.OAuth.get_key/0` directly 17 + - `Atex.OAuth.create_client_metadata/1`, `create_client_assertion/3`, `create_authorization_url/5`, `validate_authorization_code/5`, `refresh_token/5`, `revoke_tokens/2` moved to `Atex.OAuth.Flow` 18 + - `Atex.OAuth.create_dpop_token/4`, `send_oauth_dpop_request/3`, `request_protected_dpop_resource/5` moved to `Atex.OAuth.DPoP` 19 + - `Atex.OAuth.get_authorization_server/2`, `get_authorization_server_metadata/2` moved to `Atex.OAuth.Discovery` 20 + - Error atom `:invaild_issuer` corrected to `:invalid_issuer` 21 + 14 22 ## [0.9.1] - 2026-04-17 15 23 16 24 ### Fixed
+61 -776
lib/atex/oauth.ex
··· 1 1 defmodule Atex.OAuth do 2 2 @moduledoc """ 3 - OAuth 2.0 implementation for AT Protocol authentication. 3 + AT Protocol OAuth 2.0 session management. 4 4 5 - This module provides utilities for implementing OAuth flows compliant with the 6 - AT Protocol specification. It includes support for: 5 + Provides Plug session helpers for managing OAuth sessions in a web application. 6 + For the full OAuth flow, see `Atex.OAuth.Flow`. For authorization server 7 + discovery, see `Atex.OAuth.Discovery`. For DPoP token handling, see 8 + `Atex.OAuth.DPoP`. 7 9 8 - - Pushed Authorization Requests (PAR) 9 - - DPoP (Demonstration of Proof of Possession) tokens 10 - - JWT client assertions 11 - - PKCE (Proof Key for Code Exchange) 12 - - Token refresh 13 - - Handle to PDS resolution 10 + ## Type re-exports 14 11 15 - ## Configuration 12 + The following types are re-exported here for backward compatibility: 16 13 17 - See `Atex.Config.OAuth` module for configuration documentation. 18 - 19 - ## Usage Example 20 - 21 - iex> pds = "https://bsky.social" 22 - iex> login_hint = "example.com" 23 - iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds) 24 - iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server) 25 - iex> state = Atex.OAuth.create_nonce() 26 - iex> code_verifier = Atex.OAuth.create_nonce() 27 - iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url( 28 - authz_metadata, 29 - state, 30 - code_verifier, 31 - login_hint 32 - ) 14 + - `t:Atex.OAuth.Flow.authorization_metadata/0` 15 + - `t:Atex.OAuth.Flow.tokens/0` 33 16 """ 34 17 35 - @type authorization_metadata() :: %{ 36 - issuer: String.t(), 37 - par_endpoint: String.t(), 38 - token_endpoint: String.t(), 39 - authorization_endpoint: String.t(), 40 - revocation_endpoint: String.t() 41 - } 18 + alias Atex.OAuth.SessionStore 42 19 43 - @type tokens() :: %{ 44 - access_token: String.t(), 45 - refresh_token: String.t(), 46 - did: String.t(), 47 - expires_at: NaiveDateTime.t() 48 - } 49 - 50 - @type create_client_metadata_option :: 51 - {:key, JOSE.JWK.t()} 52 - | {:client_id, String.t()} 53 - | {:redirect_uri, String.t()} 54 - | {:extra_redirect_uris, list(String.t())} 55 - | {:scopes, String.t()} 56 - 57 - @type create_authorization_url_option :: 58 - {:key, JOSE.JWK.t()} 59 - | {:client_id, String.t()} 60 - | {:redirect_uri, String.t()} 61 - | {:scopes, String.t()} 62 - 63 - @type validate_authorization_code_option :: 64 - {:key, JOSE.JWK.t()} 65 - | {:client_id, String.t()} 66 - | {:redirect_uri, String.t()} 67 - | {:scopes, String.t()} 68 - 69 - @type refresh_token_option :: 70 - {:key, JOSE.JWK.t()} 71 - | {:client_id, String.t()} 72 - | {:redirect_uri, String.t()} 73 - | {:scopes, String.t()} 74 - 75 - require Logger 76 - 77 - alias Atex.Config.OAuth, as: Config 78 - alias Atex.OAuth.{Session, SessionStore} 20 + @type authorization_metadata() :: Atex.OAuth.Flow.authorization_metadata() 21 + @type tokens() :: Atex.OAuth.Flow.tokens() 79 22 80 23 @session_keys_name :atex_sessions 81 24 @session_active_name :atex_active_session 82 25 83 26 @doc """ 84 - Returns the composite session key (`"<did>:<nonce>"`) for the currently active 85 - OAuth session on the given conn. 86 - 87 - This is the primary way to identify which session is active for a request. The 88 - returned key can be passed directly to `Atex.OAuth.SessionStore.get/1` or used 89 - to construct an `Atex.XRPC.OAuthClient`. 90 - 91 - ## Returns 92 - 93 - - `{:ok, session_key}` - The composite key for the active session 94 - - `:error` - No active session found in the conn 27 + Return the session key atom used to store the list of session keys in a 28 + `Plug.Conn` session. 95 29 96 - ## Examples 97 - 98 - case Atex.OAuth.current_session_key(conn) do 99 - {:ok, key} -> {:ok, client} = Atex.XRPC.OAuthClient.new(key) 100 - :error -> redirect_to_login(conn) 101 - end 102 - 30 + Used by `Atex.OAuth.Plug` when reading and writing session data. 103 31 """ 104 - @spec current_session_key(Plug.Conn.t()) :: {:ok, String.t()} | :error 105 - def current_session_key(%Plug.Conn{} = conn) do 106 - case Plug.Conn.get_session(conn, @session_active_name) do 107 - key when is_binary(key) -> {:ok, key} 108 - _ -> :error 109 - end 110 - end 32 + @spec session_keys_name() :: atom() 33 + def session_keys_name, do: @session_keys_name 111 34 112 35 @doc """ 113 - Returns all composite session keys stored for this device's conn session. 36 + Return the session key atom used to store the active session key in a 37 + `Plug.Conn` session. 114 38 115 - Each key corresponds to a distinct authenticated account on this device. The 116 - list is ordered with the most recently logged-in account first. 117 - 118 - ## Examples 119 - 120 - keys = Atex.OAuth.list_session_keys(conn) 121 - # => ["did:plc:abc:nonce1", "did:plc:xyz:nonce2"] 122 - 39 + Used by `Atex.OAuth.Plug` when reading and writing session data. 123 40 """ 124 - @spec list_session_keys(Plug.Conn.t()) :: [String.t()] 125 - def list_session_keys(%Plug.Conn{} = conn) do 126 - Plug.Conn.get_session(conn, @session_keys_name) || [] 127 - end 41 + @spec session_active_session_name() :: atom() 42 + def session_active_session_name, do: @session_active_name 128 43 129 44 @doc """ 130 - Switches the active session to the given composite session key. 45 + Generate a random base64url-encoded nonce suitable for use in OAuth flows. 131 46 132 - Validates that the key is present in the conn's session list and that the 133 - corresponding session still exists in the store before updating the conn. 134 - 135 - ## Returns 136 - 137 - - `{:ok, conn}` - Active session switched; the returned conn has the updated 138 - session and should be used for subsequent operations 139 - - `{:error, :not_found}` - The key is not in the session list or the session 140 - no longer exists in the store 47 + Returns a 32-byte random value encoded as a URL-safe base64 string without 48 + padding. Useful when building custom authorization flows. 141 49 142 50 ## Examples 143 51 144 - case Atex.OAuth.switch_session(conn, "did:plc:xyz:nonce2") do 145 - {:ok, conn} -> send_resp(conn, 200, "Switched accounts") 146 - {:error, :not_found} -> send_resp(conn, 404, "Session not found") 147 - end 148 - 52 + iex> nonce = Atex.OAuth.create_nonce() 53 + iex> is_binary(nonce) 54 + true 149 55 """ 150 - @spec switch_session(Plug.Conn.t(), String.t()) :: {:ok, Plug.Conn.t()} | {:error, :not_found} 151 - def switch_session(%Plug.Conn{} = conn, session_key) when is_binary(session_key) do 152 - stored_keys = list_session_keys(conn) 153 - 154 - with true <- session_key in stored_keys, 155 - {:ok, _session} <- Atex.OAuth.SessionStore.get(session_key) do 156 - {:ok, Plug.Conn.put_session(conn, @session_active_name, session_key)} 157 - else 158 - _ -> {:error, :not_found} 159 - end 160 - end 161 - 162 - @doc """ 163 - Get a map containing the client metadata information needed for an 164 - authorization server to validate this client. 165 - """ 166 - @spec create_client_metadata(list(create_client_metadata_option())) :: map() 167 - def create_client_metadata(opts \\ []) do 168 - opts = 169 - Keyword.validate!( 170 - opts, 171 - [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes] 172 - ) 173 - 174 - key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 175 - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 176 - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 177 - 178 - extra_redirect_uris = 179 - Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0) 180 - 181 - scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) 182 - 183 - {_, jwk} = key |> JOSE.JWK.to_public_map() 184 - jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) 185 - 186 - %{ 187 - client_id: client_id, 188 - redirect_uris: [redirect_uri | extra_redirect_uris], 189 - application_type: "web", 190 - grant_types: ["authorization_code", "refresh_token"], 191 - scope: scopes, 192 - response_type: ["code"], 193 - token_endpoint_auth_method: "private_key_jwt", 194 - token_endpoint_auth_signing_alg: "ES256", 195 - dpop_bound_access_tokens: true, 196 - jwks: %{keys: [jwk]} 197 - } 198 - end 199 - 200 - @doc """ 201 - Retrieves the configured JWT private key for signing client assertions. 202 - 203 - Loads the private key from configuration, decodes the base64-encoded DER data, 204 - and creates a JOSE JWK structure with the key ID field set. 205 - 206 - ## Returns 207 - 208 - A `JOSE.JWK` struct containing the private key and key identifier. 209 - 210 - ## Raises 211 - 212 - * `Application.Env.Error` if the private_key or key_id configuration is missing 213 - 214 - ## Examples 215 - 216 - key = OAuth.get_key() 217 - key = OAuth.get_key() 218 - """ 219 - @spec get_key() :: JOSE.JWK.t() 220 - def get_key(), do: Config.get_key() 221 - 222 - @doc false 223 - @spec random_b64(integer()) :: String.t() 224 - def random_b64(length) do 225 - :crypto.strong_rand_bytes(length) 226 - |> Base.url_encode64(padding: false) 227 - end 228 - 229 - @doc false 230 56 @spec create_nonce() :: String.t() 231 - def create_nonce(), do: random_b64(32) 232 - 233 - @doc """ 234 - Create an OAuth authorization URL for a PDS. 235 - 236 - Submits a PAR request to the authorization server and constructs the 237 - authorization URL with the returned request URI. Supports PKCE, DPoP, and 238 - client assertions as required by the AT Protocol. 239 - 240 - ## Parameters 241 - 242 - - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1` 243 - - `state` - Random token for session validation 244 - - `code_verifier` - PKCE code verifier 245 - - `login_hint` - User identifier (handle or DID) for pre-filled login 246 - 247 - ## Returns 248 - 249 - - `{:ok, authorization_url}` - Successfully created authorization URL 250 - - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request 251 - - `{:error, reason}` - Error creating authorization URL 252 - """ 253 - @spec create_authorization_url( 254 - authorization_metadata(), 255 - String.t(), 256 - String.t(), 257 - String.t(), 258 - list(create_authorization_url_option()) 259 - ) :: {:ok, String.t()} | {:error, any()} 260 - def create_authorization_url( 261 - authz_metadata, 262 - state, 263 - code_verifier, 264 - login_hint, 265 - opts \\ [] 266 - ) do 267 - opts = 268 - Keyword.validate!( 269 - opts, 270 - [:key, :client_id, :redirect_uri, :scopes] 271 - ) 272 - 273 - key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 274 - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 275 - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 276 - scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) 277 - 278 - code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) 279 - 280 - client_assertion = 281 - create_client_assertion(key, client_id, authz_metadata.issuer) 282 - 283 - body = 284 - %{ 285 - response_type: "code", 286 - client_id: client_id, 287 - redirect_uri: redirect_uri, 288 - state: state, 289 - code_challenge_method: "S256", 290 - code_challenge: code_challenge, 291 - scope: scopes, 292 - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 293 - client_assertion: client_assertion, 294 - login_hint: login_hint 295 - } 296 - 297 - case Req.post(authz_metadata.par_endpoint, form: body) do 298 - {:ok, %{body: %{"request_uri" => request_uri}}} -> 299 - query = 300 - %{client_id: client_id, request_uri: request_uri} 301 - |> URI.encode_query() 302 - 303 - {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"} 304 - 305 - {:ok, _} -> 306 - {:error, :invalid_par_response} 307 - 308 - err -> 309 - err 310 - end 57 + def create_nonce do 58 + :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) 311 59 end 312 60 313 61 @doc """ 314 - Exchange an OAuth authorization code for a set of access and refresh tokens. 62 + Get the key of the currently active OAuth session from the connection. 315 63 316 - Validates the authorization code by submitting it to the token endpoint along with 317 - the PKCE code verifier and client assertion. Returns access tokens for making authenticated 318 - requests to the relevant user's PDS. 64 + Returns `nil` if no session is currently active. 319 65 320 66 ## Parameters 321 67 322 - - `authz_metadata` - Authorization server metadata containing token endpoint 323 - - `dpop_key` - JWK for DPoP token generation 324 - - `code` - Authorization code from OAuth callback 325 - - `code_verifier` - PKCE code verifier from authorization flow 326 - 327 - ## Returns 328 - 329 - - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce 330 - - `{:error, reason}` - Error exchanging code for tokens 68 + - `conn` - A `Plug.Conn` with session data loaded 331 69 """ 332 - @spec validate_authorization_code( 333 - authorization_metadata(), 334 - JOSE.JWK.t(), 335 - String.t(), 336 - String.t(), 337 - list(validate_authorization_code_option()) 338 - ) :: {:ok, tokens(), String.t()} | {:error, any()} 339 - def validate_authorization_code( 340 - authz_metadata, 341 - dpop_key, 342 - code, 343 - code_verifier, 344 - opts \\ [] 345 - ) do 346 - opts = 347 - Keyword.validate!( 348 - opts, 349 - [:key, :client_id, :redirect_uri, :scopes] 350 - ) 351 - 352 - key = Keyword.get_lazy(opts, :key, &get_key/0) 353 - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 354 - redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 355 - 356 - client_assertion = 357 - create_client_assertion(key, client_id, authz_metadata.issuer) 358 - 359 - body = 360 - %{ 361 - grant_type: "authorization_code", 362 - client_id: client_id, 363 - redirect_uri: redirect_uri, 364 - code: code, 365 - code_verifier: code_verifier 366 - } 367 - 368 - body = 369 - if Config.is_localhost(), 370 - do: body, 371 - else: 372 - Map.merge(body, %{ 373 - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 374 - client_assertion: client_assertion 375 - }) 376 - 377 - Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) 378 - |> send_oauth_dpop_request(dpop_key) 379 - |> case do 380 - {:ok, 381 - %{ 382 - "access_token" => access_token, 383 - "refresh_token" => refresh_token, 384 - "expires_in" => expires_in, 385 - "sub" => did 386 - }, nonce} -> 387 - expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 388 - 389 - {:ok, 390 - %{ 391 - access_token: access_token, 392 - refresh_token: refresh_token, 393 - did: did, 394 - expires_at: expires_at 395 - }, nonce} 396 - 397 - err -> 398 - err 399 - end 400 - end 401 - 402 - @spec refresh_token( 403 - String.t(), 404 - JOSE.JWK.t(), 405 - String.t(), 406 - String.t(), 407 - list(refresh_token_option()) 408 - ) :: 409 - {:ok, tokens(), String.t()} | {:error, any()} 410 - def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do 411 - opts = 412 - Keyword.validate!( 413 - opts, 414 - [:key, :client_id, :redirect_uri, :scopes] 415 - ) 416 - 417 - key = Keyword.get_lazy(opts, :key, &get_key/0) 418 - client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 419 - 420 - client_assertion = 421 - create_client_assertion(key, client_id, issuer) 422 - 423 - body = %{ 424 - grant_type: "refresh_token", 425 - refresh_token: refresh_token, 426 - client_id: client_id, 427 - client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 428 - client_assertion: client_assertion 429 - } 430 - 431 - Req.new(method: :post, url: token_endpoint, form: body) 432 - |> send_oauth_dpop_request(dpop_key) 433 - |> case do 434 - {:ok, 435 - %{ 436 - "access_token" => access_token, 437 - "refresh_token" => refresh_token, 438 - "expires_in" => expires_in, 439 - "sub" => did 440 - }, nonce} -> 441 - expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 442 - 443 - {:ok, 444 - %{ 445 - access_token: access_token, 446 - refresh_token: refresh_token, 447 - did: did, 448 - expires_at: expires_at 449 - }, nonce} 450 - 451 - err -> 452 - err 453 - end 70 + @spec current_session_key(Plug.Conn.t()) :: String.t() | nil 71 + def current_session_key(conn) do 72 + Plug.Conn.get_session(conn, @session_active_name) 454 73 end 455 74 456 75 @doc """ 457 - Fetch the authorization server for a given Personal Data Server (PDS). 458 - 459 - Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint 460 - to discover the associated authorization server that should be used for the 461 - OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs. 76 + List all OAuth session keys stored in the connection's session. 462 77 463 78 ## Parameters 464 79 465 - - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 466 - - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 467 - 468 - ## Returns 469 - 470 - - `{:ok, authorization_server}` - Successfully discovered authorization 471 - server URL 472 - - `{:error, :invalid_metadata}` - Server returned invalid metadata 473 - - `{:error, reason}` - Error discovering authorization server 80 + - `conn` - A `Plug.Conn` with session data loaded 474 81 """ 475 - @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} 476 - def get_authorization_server(pds_host, fresh \\ false) do 477 - if fresh do 478 - fetch_authorization_server(pds_host) 479 - else 480 - case Atex.OAuth.Cache.get_authorization_server(pds_host) do 481 - {:ok, authz_server} -> 482 - {:ok, authz_server} 483 - 484 - {:error, :not_found} -> 485 - fetch_authorization_server(pds_host) 486 - end 487 - end 488 - end 489 - 490 - defp fetch_authorization_server(pds_host) do 491 - result = 492 - "#{pds_host}/.well-known/oauth-protected-resource" 493 - |> Req.get() 494 - |> case do 495 - # TODO: what to do when multiple authorization servers? 496 - {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} 497 - {:ok, _} -> {:error, :invalid_metadata} 498 - err -> err 499 - end 500 - 501 - case result do 502 - {:ok, authz_server} -> 503 - Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server) 504 - {:ok, authz_server} 505 - 506 - error -> 507 - error 508 - end 509 - end 510 - 511 - @doc """ 512 - Fetch the metadata for an OAuth authorization server. 513 - 514 - Retrieves the metadata from the authorization server's 515 - `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs 516 - required for the OAuth flow. Results are cached for 1 hour to reduce load on 517 - third-party PDSs. 518 - 519 - ## Parameters 520 - 521 - - `issuer` - Authorization server issuer URL 522 - - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 523 - 524 - ## Returns 525 - 526 - - `{:ok, metadata}` - Successfully retrieved authorization server metadata 527 - - `{:error, :invalid_metadata}` - Server returned invalid metadata 528 - - `{:error, :invalid_issuer}` - Issuer mismatch in metadata 529 - - `{:error, any()}` - Other error fetching metadata 530 - """ 531 - @spec get_authorization_server_metadata(String.t(), boolean()) :: 532 - {:ok, authorization_metadata()} | {:error, any()} 533 - def get_authorization_server_metadata(issuer, fresh \\ false) do 534 - if fresh do 535 - fetch_authorization_server_metadata(issuer) 536 - else 537 - case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do 538 - {:ok, metadata} -> 539 - {:ok, metadata} 540 - 541 - {:error, :not_found} -> 542 - fetch_authorization_server_metadata(issuer) 543 - end 544 - end 545 - end 546 - 547 - defp fetch_authorization_server_metadata(issuer) do 548 - result = 549 - "#{issuer}/.well-known/oauth-authorization-server" 550 - |> Req.get() 551 - |> case do 552 - {:ok, 553 - %{ 554 - body: %{ 555 - "issuer" => metadata_issuer, 556 - "pushed_authorization_request_endpoint" => par_endpoint, 557 - "token_endpoint" => token_endpoint, 558 - "authorization_endpoint" => authorization_endpoint, 559 - "revocation_endpoint" => revocation_endpoint 560 - } 561 - }} -> 562 - if issuer != metadata_issuer do 563 - {:error, :invaild_issuer} 564 - else 565 - {:ok, 566 - %{ 567 - issuer: metadata_issuer, 568 - par_endpoint: par_endpoint, 569 - token_endpoint: token_endpoint, 570 - authorization_endpoint: authorization_endpoint, 571 - revocation_endpoint: revocation_endpoint 572 - }} 573 - end 574 - 575 - {:ok, _} -> 576 - {:error, :invalid_metadata} 577 - 578 - err -> 579 - err 580 - end 581 - 582 - case result do 583 - {:ok, metadata} -> 584 - Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata) 585 - {:ok, metadata} 586 - 587 - error -> 588 - error 589 - end 590 - end 591 - 592 - @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: 593 - {:ok, map(), String.t()} | {:error, any(), String.t()} 594 - def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do 595 - dpop_token = create_dpop_token(dpop_key, request, nonce) 596 - 597 - request 598 - |> Req.Request.put_header("dpop", dpop_token) 599 - |> Req.request() 600 - |> case do 601 - {:ok, resp} -> 602 - dpop_nonce = 603 - case resp.headers["dpop-nonce"] do 604 - [new_nonce | _] -> new_nonce 605 - _ -> nonce 606 - end 607 - 608 - cond do 609 - resp.status == 200 -> 610 - {:ok, resp.body, dpop_nonce} 611 - 612 - resp.body["error"] === "use_dpop_nonce" -> 613 - dpop_token = create_dpop_token(dpop_key, request, dpop_nonce) 614 - 615 - request 616 - |> Req.Request.put_header("dpop", dpop_token) 617 - |> Req.request() 618 - |> case do 619 - {:ok, %{status: 200, body: body}} -> 620 - {:ok, body, dpop_nonce} 621 - 622 - {:ok, %{body: %{"error" => error, "error_description" => error_description}}} -> 623 - {:error, {:oauth_error, error, error_description}, dpop_nonce} 624 - 625 - {:ok, _} -> 626 - {:error, :unexpected_response, dpop_nonce} 627 - 628 - {:error, err} -> 629 - {:error, err, dpop_nonce} 630 - end 631 - 632 - true -> 633 - {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]}, 634 - dpop_nonce} 635 - end 636 - 637 - {:error, err} -> 638 - {:error, err, nonce} 639 - end 640 - end 641 - 642 - @spec request_protected_dpop_resource( 643 - Req.Request.t(), 644 - String.t(), 645 - String.t(), 646 - JOSE.JWK.t(), 647 - String.t() | nil 648 - ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} 649 - def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do 650 - access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) 651 - # access_token_hash = Base.url_encode64(access_token, padding: false) 652 - 653 - dpop_token = 654 - create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash}) 655 - 656 - request 657 - |> Req.Request.put_header("dpop", dpop_token) 658 - |> Req.request() 659 - |> case do 660 - {:ok, resp} -> 661 - dpop_nonce = 662 - case resp.headers["dpop-nonce"] do 663 - [new_nonce | _] -> new_nonce 664 - _ -> nonce 665 - end 666 - 667 - www_authenticate = Req.Response.get_header(resp, "www-authenticate") 668 - 669 - www_dpop_problem = 670 - www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP") 671 - 672 - if resp.status != 401 || !www_dpop_problem do 673 - {:ok, resp, dpop_nonce} 674 - else 675 - dpop_token = 676 - create_dpop_token(dpop_key, request, dpop_nonce, %{ 677 - iss: issuer, 678 - ath: access_token_hash 679 - }) 680 - 681 - request 682 - |> Req.Request.put_header("dpop", dpop_token) 683 - |> Req.request() 684 - |> case do 685 - {:ok, resp} -> 686 - dpop_nonce = 687 - case resp.headers["dpop-nonce"] do 688 - [new_nonce | _] -> new_nonce 689 - _ -> dpop_nonce 690 - end 691 - 692 - {:ok, resp, dpop_nonce} 693 - 694 - err -> 695 - err 696 - end 697 - end 698 - end 82 + @spec list_session_keys(Plug.Conn.t()) :: list(String.t()) 83 + def list_session_keys(conn) do 84 + Plug.Conn.get_session(conn, @session_keys_name) || [] 699 85 end 700 86 701 87 @doc """ 702 - Revokes the access and refresh tokens with the authorization server. 88 + Switch the active OAuth session to the given key. 703 89 704 - Sends both tokens to the revocation endpoint as defined in RFC 7009. 705 - This invalidates the tokens on the PDS side, preventing further use. 90 + Updates the `:atex_active_session` value in the Plug session. 706 91 707 92 ## Parameters 708 93 709 - - `session` - The session containing tokens to revoke 710 - - `authz_metadata` - Authorization server metadata including `revocation_endpoint` 711 - 712 - ## Returns 713 - 714 - - `:ok` - Tokens successfully revoked (or revocation endpoint unreachable) 715 - - `{:error, reason}` - Revocation failed 716 - 94 + - `conn` - A `Plug.Conn` with session data loaded 95 + - `session_key` - The session key to make active 717 96 """ 718 - @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok | {:error, any()} 719 - def revoke_tokens(%Session{} = session, authz_metadata) do 720 - client_id = Config.client_id() 721 - 722 - body = %{ 723 - client_id: client_id, 724 - token: session.refresh_token, 725 - token_type_hint: "refresh_token" 726 - } 727 - 728 - case Req.post(authz_metadata.revocation_endpoint, form: body) do 729 - {:ok, %{status: status}} when status in [200, 204] -> 730 - :ok 731 - 732 - {:ok, %{body: %{"error" => error}}} -> 733 - Logger.warning("Token revocation failed: #{error}") 734 - :ok 735 - 736 - {:error, reason} -> 737 - Logger.warning("Token revocation request failed: #{inspect(reason)}") 738 - :ok 739 - 740 - unexpected -> 741 - Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") 742 - :ok 743 - end 97 + @spec switch_session(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 98 + def switch_session(conn, session_key) do 99 + Plug.Conn.put_session(conn, @session_active_name, session_key) 744 100 end 745 101 746 102 @doc """ 747 - Deletes a session from the store and revokes its tokens. 103 + Delete the currently active OAuth session. 748 104 749 - This is the primary function for logging out a session. It: 750 - 1. Fetches the session data from the store if a key is provided 751 - 2. Revokes the tokens with the authorization server 752 - 3. Removes the session from the store 105 + Removes the active session from `SessionStore`, removes its key from the 106 + session key list, and clears the active session pointer in the Plug session. 753 107 754 108 ## Parameters 755 109 756 - - `session_or_key` - Either a `Session.t()` struct or a composite session key string 757 - 758 - ## Returns 759 - 760 - - `:ok` - Session deleted and tokens revoked 761 - - `{:error, :not_found}` - Session not found in store 762 - - `{:error, reason}` - Token revocation or store deletion failed 763 - 764 - ## Examples 765 - 766 - # Using a session key 767 - case Atex.OAuth.delete_session("did:plc:abc123:device-nonce") do 768 - :ok -> :logged_out 769 - {:error, :not_found} -> :session_already_gone 770 - end 771 - 772 - # Using a session struct 773 - {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123:device-nonce") 774 - :ok = Atex.OAuth.delete_session(session) 775 - 110 + - `conn` - A `Plug.Conn` with session data loaded 776 111 """ 777 - @spec delete_session(Session.t() | String.t()) :: :ok | {:error, :not_found | any()} 778 - def delete_session(%Session{} = session) do 779 - with {:ok, authz_metadata} <- get_authorization_server_metadata(session.iss, true), 780 - :ok <- revoke_tokens(session, authz_metadata) do 781 - SessionStore.delete(session) 782 - end 783 - end 112 + @spec delete_session(Plug.Conn.t()) :: Plug.Conn.t() 113 + def delete_session(conn) do 114 + session_key = current_session_key(conn) 784 115 785 - def delete_session(session_key) when is_binary(session_key) do 786 - case SessionStore.get(session_key) do 787 - {:ok, session} -> delete_session(session) 788 - {:error, reason} -> {:error, reason} 116 + if session_key do 117 + SessionStore.delete(session_key) 789 118 end 790 - end 791 119 792 - @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() 793 - def create_client_assertion(jwk, client_id, issuer) do 794 - iat = System.os_time(:second) 795 - jti = random_b64(20) 796 - jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} 797 - 798 - jwt = %{ 799 - iss: client_id, 800 - sub: client_id, 801 - aud: issuer, 802 - jti: jti, 803 - iat: iat, 804 - exp: iat + 60 805 - } 806 - 807 - JOSE.JWT.sign(jwk, jws, jwt) 808 - |> JOSE.JWS.compact() 809 - |> elem(1) 810 - end 811 - 812 - @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t() 813 - def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do 814 - iat = System.os_time(:second) 815 - jti = random_b64(20) 816 - {_, public_jwk} = JOSE.JWK.to_public_map(jwk) 817 - jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk} 818 - [request_url | _] = request.url |> to_string() |> String.split("?") 819 - 820 - jwt = 821 - Map.merge(attrs, %{ 822 - jti: jti, 823 - htm: atom_to_upcase_string(request.method), 824 - htu: request_url, 825 - iat: iat 826 - }) 827 - |> then(fn m -> 828 - if nonce, do: Map.put(m, :nonce, nonce), else: m 829 - end) 120 + session_keys = list_session_keys(conn) |> List.delete(session_key) 830 121 831 - JOSE.JWT.sign(jwk, jws, jwt) 832 - |> JOSE.JWS.compact() 833 - |> elem(1) 834 - end 835 - 836 - @doc false 837 - @spec atom_to_upcase_string(atom()) :: String.t() 838 - def atom_to_upcase_string(atom) do 839 - atom |> to_string() |> String.upcase() 122 + conn 123 + |> Plug.Conn.put_session(@session_keys_name, session_keys) 124 + |> Plug.Conn.delete_session(@session_active_name) 840 125 end 841 126 end
+144
lib/atex/oauth/discovery.ex
··· 1 + defmodule Atex.OAuth.Discovery do 2 + @moduledoc """ 3 + Authorization server discovery for AT Protocol OAuth. 4 + 5 + Resolves a PDS to its authorization server and fetches authorization server 6 + metadata. Results are cached for 1 hour via `Atex.OAuth.Cache`. 7 + """ 8 + 9 + alias Atex.OAuth.Cache 10 + 11 + @doc """ 12 + Fetch the authorization server for a given Personal Data Server (PDS). 13 + 14 + Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint. 15 + Results are cached for 1 hour to reduce load on third-party PDSs. 16 + 17 + ## Parameters 18 + 19 + - `pds_host` - Base URL of the PDS (e.g., `"https://bsky.social"`) 20 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 21 + 22 + ## Returns 23 + 24 + - `{:ok, authorization_server}` - Successfully discovered authorization server URL 25 + - `{:error, :invalid_metadata}` - Server returned invalid metadata 26 + - `{:error, reason}` - Error discovering authorization server 27 + """ 28 + @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} 29 + def get_authorization_server(pds_host, fresh \\ false) do 30 + if fresh do 31 + fetch_authorization_server(pds_host) 32 + else 33 + case Cache.get_authorization_server(pds_host) do 34 + {:ok, authz_server} -> {:ok, authz_server} 35 + {:error, :not_found} -> fetch_authorization_server(pds_host) 36 + end 37 + end 38 + end 39 + 40 + @doc """ 41 + Fetch the metadata for an OAuth authorization server. 42 + 43 + Retrieves the metadata from `.well-known/oauth-authorization-server`. 44 + Results are cached for 1 hour. 45 + 46 + ## Parameters 47 + 48 + - `issuer` - Authorization server issuer URL 49 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 50 + 51 + ## Returns 52 + 53 + - `{:ok, metadata}` - Successfully retrieved authorization server metadata 54 + - `{:error, :invalid_metadata}` - Server returned invalid metadata 55 + - `{:error, :invalid_issuer}` - Issuer mismatch in metadata 56 + - `{:error, any()}` - Other error fetching metadata 57 + """ 58 + @spec get_authorization_server_metadata(String.t(), boolean()) :: 59 + {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()} 60 + def get_authorization_server_metadata(issuer, fresh \\ false) do 61 + if fresh do 62 + fetch_authorization_server_metadata(issuer) 63 + else 64 + case Cache.get_authorization_server_metadata(issuer) do 65 + {:ok, metadata} -> {:ok, metadata} 66 + {:error, :not_found} -> fetch_authorization_server_metadata(issuer) 67 + end 68 + end 69 + end 70 + 71 + @spec fetch_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()} 72 + defp fetch_authorization_server(pds_host) do 73 + result = 74 + "#{pds_host}/.well-known/oauth-protected-resource" 75 + |> Req.get() 76 + |> case do 77 + # TODO: what to do when multiple authorization servers? 78 + {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> 79 + {:ok, authz_server} 80 + 81 + {:ok, _} -> 82 + {:error, :invalid_metadata} 83 + 84 + err -> 85 + err 86 + end 87 + 88 + case result do 89 + {:ok, authz_server} -> 90 + Cache.set_authorization_server(pds_host, authz_server) 91 + {:ok, authz_server} 92 + 93 + error -> 94 + error 95 + end 96 + end 97 + 98 + @spec fetch_authorization_server_metadata(String.t()) :: 99 + {:ok, Atex.OAuth.Flow.authorization_metadata()} | {:error, any()} 100 + defp fetch_authorization_server_metadata(issuer) do 101 + result = 102 + "#{issuer}/.well-known/oauth-authorization-server" 103 + |> Req.get() 104 + |> case do 105 + {:ok, 106 + %{ 107 + body: %{ 108 + "issuer" => metadata_issuer, 109 + "pushed_authorization_request_endpoint" => par_endpoint, 110 + "token_endpoint" => token_endpoint, 111 + "authorization_endpoint" => authorization_endpoint, 112 + "revocation_endpoint" => revocation_endpoint 113 + } 114 + }} -> 115 + if issuer != metadata_issuer do 116 + {:error, :invalid_issuer} 117 + else 118 + {:ok, 119 + %{ 120 + issuer: metadata_issuer, 121 + par_endpoint: par_endpoint, 122 + token_endpoint: token_endpoint, 123 + authorization_endpoint: authorization_endpoint, 124 + revocation_endpoint: revocation_endpoint 125 + }} 126 + end 127 + 128 + {:ok, _} -> 129 + {:error, :invalid_metadata} 130 + 131 + err -> 132 + err 133 + end 134 + 135 + case result do 136 + {:ok, metadata} -> 137 + Cache.set_authorization_server_metadata(issuer, metadata) 138 + {:ok, metadata} 139 + 140 + error -> 141 + error 142 + end 143 + end 144 + end
+185
lib/atex/oauth/dpop.ex
··· 1 + defmodule Atex.OAuth.DPoP do 2 + @moduledoc """ 3 + DPoP (Demonstrating Proof of Possession) token creation and request handling. 4 + 5 + Provides functions to create DPoP proof JWTs and send DPoP-protected HTTP 6 + requests, handling the nonce retry dance required by the AT Protocol OAuth 7 + specification. 8 + """ 9 + 10 + @doc """ 11 + Create a DPoP proof token for a given request. 12 + 13 + Builds a signed JWT containing the HTTP method, URL (without query string), 14 + a random `jti`, the current timestamp, and an optional server nonce. Extra 15 + claims (e.g., `iss`, `ath`) can be merged in via `attrs`. 16 + 17 + ## Parameters 18 + 19 + - `jwk` - Private JWK used to sign the proof 20 + - `request` - The `Req.Request` the token is being produced for 21 + - `nonce` - Server-provided nonce (optional; omitted from JWT when `nil`) 22 + - `attrs` - Extra claims to merge into the JWT payload (default: `%{}`) 23 + """ 24 + @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), String.t() | nil, map()) :: String.t() 25 + def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do 26 + iat = System.os_time(:second) 27 + jti = random_b64(20) 28 + {_, public_jwk} = JOSE.JWK.to_public_map(jwk) 29 + jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk} 30 + [request_url | _] = request.url |> to_string() |> String.split("?") 31 + 32 + jwt = 33 + Map.merge(attrs, %{ 34 + jti: jti, 35 + htm: request.method |> to_string() |> String.upcase(), 36 + htu: request_url, 37 + iat: iat 38 + }) 39 + |> then(fn m -> if nonce, do: Map.put(m, :nonce, nonce), else: m end) 40 + 41 + JOSE.JWT.sign(jwk, jws, jwt) 42 + |> JOSE.JWS.compact() 43 + |> elem(1) 44 + end 45 + 46 + @doc """ 47 + Send a DPoP-protected request to a token endpoint. 48 + 49 + Attaches a DPoP proof to `request` and sends it. If the server responds with 50 + `use_dpop_nonce`, retries once with the returned nonce. 51 + 52 + ## Parameters 53 + 54 + - `request` - A `Req.Request` already configured with URL, method, and body 55 + - `dpop_key` - Private JWK for signing the DPoP proof 56 + - `nonce` - Current DPoP nonce, if any (default: `nil`) 57 + """ 58 + @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: 59 + {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil} 60 + def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do 61 + dpop_token = create_dpop_token(dpop_key, request, nonce) 62 + 63 + request 64 + |> Req.Request.put_header("dpop", dpop_token) 65 + |> Req.request() 66 + |> case do 67 + {:ok, %{status: 200, body: body} = resp} -> 68 + {:ok, body, extract_nonce(resp, nonce)} 69 + 70 + {:ok, %{body: %{"error" => "use_dpop_nonce"}} = resp} -> 71 + retry_token_request(request, dpop_key, extract_nonce(resp, nonce)) 72 + 73 + {:ok, %{body: %{"error" => error, "error_description" => description}} = resp} -> 74 + {:error, {:oauth_error, error, description}, extract_nonce(resp, nonce)} 75 + 76 + {:ok, resp} -> 77 + {:error, :unexpected_response, extract_nonce(resp, nonce)} 78 + 79 + {:error, err} -> 80 + {:error, err, nonce} 81 + end 82 + end 83 + 84 + @doc """ 85 + Send a DPoP-protected request to a resource server (e.g., a PDS endpoint). 86 + 87 + Attaches both the `Authorization: DPoP <token>` header (assumed already set on 88 + `request`) and a fresh DPoP proof. If the server returns a 401 with a 89 + `WWW-Authenticate: DPoP ...` header, retries once with the returned nonce. 90 + 91 + ## Parameters 92 + 93 + - `request` - A `Req.Request` with the Authorization header already set 94 + - `issuer` - Authorization server issuer URL (used in the `iss` claim) 95 + - `access_token` - The access token (used to compute the `ath` hash claim) 96 + - `dpop_key` - Private JWK for signing the DPoP proof 97 + - `nonce` - Current DPoP nonce, if any (default: `nil`) 98 + """ 99 + @spec request_protected_dpop_resource( 100 + Req.Request.t(), 101 + String.t(), 102 + String.t(), 103 + JOSE.JWK.t(), 104 + String.t() | nil 105 + ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} 106 + def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do 107 + access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false) 108 + extra_claims = %{iss: issuer, ath: access_token_hash} 109 + dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims) 110 + 111 + request 112 + |> Req.Request.put_header("dpop", dpop_token) 113 + |> Req.request() 114 + |> case do 115 + {:ok, %{status: 401} = resp} -> 116 + dpop_nonce = extract_nonce(resp, nonce) 117 + 118 + case Req.Response.get_header(resp, "www-authenticate") do 119 + ["DPoP" <> _ | _] -> retry_resource_request(request, dpop_key, dpop_nonce, extra_claims) 120 + _ -> {:ok, resp, dpop_nonce} 121 + end 122 + 123 + {:ok, resp} -> 124 + {:ok, resp, extract_nonce(resp, nonce)} 125 + 126 + {:error, _} = err -> 127 + err 128 + end 129 + end 130 + 131 + @spec retry_token_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) :: 132 + {:ok, map(), String.t() | nil} | {:error, any(), String.t() | nil} 133 + defp retry_token_request(request, dpop_key, nonce) do 134 + dpop_token = create_dpop_token(dpop_key, request, nonce) 135 + 136 + request 137 + |> Req.Request.put_header("dpop", dpop_token) 138 + |> Req.request() 139 + |> case do 140 + {:ok, %{status: 200, body: body}} -> 141 + {:ok, body, nonce} 142 + 143 + {:ok, %{body: %{"error" => error, "error_description" => description}}} -> 144 + {:error, {:oauth_error, error, description}, nonce} 145 + 146 + {:ok, _} -> 147 + {:error, :unexpected_response, nonce} 148 + 149 + {:error, err} -> 150 + {:error, err, nonce} 151 + end 152 + end 153 + 154 + @spec retry_resource_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil, map()) :: 155 + {:ok, Req.Response.t(), String.t() | nil} | {:error, any()} 156 + defp retry_resource_request(request, dpop_key, nonce, extra_claims) do 157 + dpop_token = create_dpop_token(dpop_key, request, nonce, extra_claims) 158 + 159 + request 160 + |> Req.Request.put_header("dpop", dpop_token) 161 + |> Req.request() 162 + |> case do 163 + {:ok, resp} -> 164 + dpop_nonce = extract_nonce(resp, nonce) 165 + {:ok, resp, dpop_nonce} 166 + 167 + {:error, _} = err -> 168 + err 169 + end 170 + end 171 + 172 + @spec extract_nonce(Req.Response.t(), String.t() | nil) :: String.t() | nil 173 + defp extract_nonce(resp, fallback) do 174 + case resp.headers["dpop-nonce"] do 175 + [new_nonce | _] -> new_nonce 176 + _ -> fallback 177 + end 178 + end 179 + 180 + @spec random_b64(integer()) :: String.t() 181 + defp random_b64(length) do 182 + :crypto.strong_rand_bytes(length) 183 + |> Base.url_encode64(padding: false) 184 + end 185 + end
+377
lib/atex/oauth/flow.ex
··· 1 + defmodule Atex.OAuth.Flow do 2 + @moduledoc """ 3 + AT Protocol OAuth 2.0 authorization flow. 4 + 5 + Handles the full OAuth protocol interactions: pushed authorization requests 6 + (PAR), authorization code exchange, token refresh, token revocation, client 7 + metadata, and client assertions. 8 + 9 + See `Atex.OAuth.Discovery` for authorization server discovery and 10 + `Atex.OAuth.DPoP` for DPoP token creation. 11 + """ 12 + 13 + require Logger 14 + 15 + alias Atex.Config.OAuth, as: Config 16 + alias Atex.OAuth.{DPoP, Session} 17 + 18 + @type authorization_metadata() :: %{ 19 + issuer: String.t(), 20 + par_endpoint: String.t(), 21 + token_endpoint: String.t(), 22 + authorization_endpoint: String.t(), 23 + revocation_endpoint: String.t() 24 + } 25 + 26 + @type tokens() :: %{ 27 + access_token: String.t(), 28 + refresh_token: String.t(), 29 + did: String.t(), 30 + expires_at: NaiveDateTime.t() 31 + } 32 + 33 + @type create_client_metadata_option :: 34 + {:key, JOSE.JWK.t()} 35 + | {:client_id, String.t()} 36 + | {:redirect_uri, String.t()} 37 + | {:extra_redirect_uris, list(String.t())} 38 + | {:scopes, String.t()} 39 + 40 + @type create_authorization_url_option :: 41 + {:key, JOSE.JWK.t()} 42 + | {:client_id, String.t()} 43 + | {:redirect_uri, String.t()} 44 + | {:scopes, String.t()} 45 + 46 + @type validate_authorization_code_option :: 47 + {:key, JOSE.JWK.t()} 48 + | {:client_id, String.t()} 49 + | {:redirect_uri, String.t()} 50 + | {:scopes, String.t()} 51 + 52 + @type refresh_token_option :: 53 + {:key, JOSE.JWK.t()} 54 + | {:client_id, String.t()} 55 + 56 + @doc """ 57 + Get a map containing the client metadata information needed for an 58 + authorization server to validate this client. 59 + """ 60 + @spec create_client_metadata(list(create_client_metadata_option())) :: map() 61 + def create_client_metadata(opts \\ []) do 62 + opts = 63 + Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :extra_redirect_uris, :scopes]) 64 + 65 + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 66 + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 67 + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 68 + 69 + extra_redirect_uris = 70 + Keyword.get_lazy(opts, :extra_redirect_uris, &Config.extra_redirect_uris/0) 71 + 72 + scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) 73 + 74 + {_, jwk} = key |> JOSE.JWK.to_public_map() 75 + jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]}) 76 + 77 + %{ 78 + client_id: client_id, 79 + redirect_uris: [redirect_uri | extra_redirect_uris], 80 + application_type: "web", 81 + grant_types: ["authorization_code", "refresh_token"], 82 + scope: scopes, 83 + response_type: ["code"], 84 + token_endpoint_auth_method: "private_key_jwt", 85 + token_endpoint_auth_signing_alg: "ES256", 86 + dpop_bound_access_tokens: true, 87 + jwks: %{keys: [jwk]} 88 + } 89 + end 90 + 91 + @doc """ 92 + Create a JWT client assertion for authenticating with an authorization server. 93 + 94 + Signs a short-lived (60 second) JWT with the client's private key, identifying 95 + the client to the authorization server. 96 + 97 + ## Parameters 98 + 99 + - `jwk` - Client private key (must have a `kid` field set) 100 + - `client_id` - OAuth client identifier 101 + - `issuer` - Authorization server issuer URL (used as `aud`) 102 + """ 103 + @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t() 104 + def create_client_assertion(jwk, client_id, issuer) do 105 + iat = System.os_time(:second) 106 + jti = random_b64(20) 107 + jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]} 108 + 109 + jwt = %{ 110 + iss: client_id, 111 + sub: client_id, 112 + aud: issuer, 113 + jti: jti, 114 + iat: iat, 115 + exp: iat + 60 116 + } 117 + 118 + JOSE.JWT.sign(jwk, jws, jwt) 119 + |> JOSE.JWS.compact() 120 + |> elem(1) 121 + end 122 + 123 + @doc """ 124 + Create an OAuth authorization URL for a PDS. 125 + 126 + Submits a PAR request to the authorization server and constructs the 127 + authorization URL with the returned request URI. Supports PKCE, DPoP, and 128 + client assertions as required by the AT Protocol. 129 + 130 + ## Parameters 131 + 132 + - `authz_metadata` - Authorization server metadata, from `Atex.OAuth.Discovery.get_authorization_server_metadata/2` 133 + - `state` - Random token for session validation 134 + - `code_verifier` - PKCE code verifier 135 + - `login_hint` - User identifier (handle or DID) for pre-filled login 136 + - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes` 137 + 138 + ## Returns 139 + 140 + - `{:ok, authorization_url}` - Successfully created authorization URL 141 + - `{:error, :invalid_par_response}` - Server responded incorrectly to the PAR request 142 + - `{:error, reason}` - Error creating authorization URL 143 + """ 144 + @spec create_authorization_url( 145 + authorization_metadata(), 146 + String.t(), 147 + String.t(), 148 + String.t(), 149 + list(create_authorization_url_option()) 150 + ) :: {:ok, String.t()} | {:error, any()} 151 + def create_authorization_url(authz_metadata, state, code_verifier, login_hint, opts \\ []) do 152 + opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes]) 153 + 154 + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 155 + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 156 + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 157 + scopes = Keyword.get_lazy(opts, :scopes, &Config.scopes/0) 158 + 159 + code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false) 160 + client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer) 161 + 162 + body = %{ 163 + response_type: "code", 164 + client_id: client_id, 165 + redirect_uri: redirect_uri, 166 + state: state, 167 + code_challenge_method: "S256", 168 + code_challenge: code_challenge, 169 + scope: scopes, 170 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 171 + client_assertion: client_assertion, 172 + login_hint: login_hint 173 + } 174 + 175 + case Req.post(authz_metadata.par_endpoint, form: body) do 176 + {:ok, %{body: %{"request_uri" => request_uri}}} -> 177 + query = %{client_id: client_id, request_uri: request_uri} |> URI.encode_query() 178 + {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"} 179 + 180 + {:ok, _} -> 181 + {:error, :invalid_par_response} 182 + 183 + err -> 184 + err 185 + end 186 + end 187 + 188 + @doc """ 189 + Exchange an OAuth authorization code for a set of access and refresh tokens. 190 + 191 + Validates the authorization code by submitting it to the token endpoint along 192 + with the PKCE code verifier and client assertion. Returns access tokens for 193 + making authenticated requests to the relevant user's PDS. 194 + 195 + ## Parameters 196 + 197 + - `authz_metadata` - Authorization server metadata containing token endpoint 198 + - `dpop_key` - JWK for DPoP token generation 199 + - `code` - Authorization code from OAuth callback 200 + - `code_verifier` - PKCE code verifier from authorization flow 201 + - `opts` - Optional overrides for `:key`, `:client_id`, `:redirect_uri`, `:scopes` 202 + 203 + ## Returns 204 + 205 + - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce 206 + - `{:error, reason}` - Error exchanging code for tokens 207 + """ 208 + @spec validate_authorization_code( 209 + authorization_metadata(), 210 + JOSE.JWK.t(), 211 + String.t(), 212 + String.t(), 213 + list(validate_authorization_code_option()) 214 + ) :: {:ok, tokens(), String.t() | nil} | {:error, any()} 215 + def validate_authorization_code(authz_metadata, dpop_key, code, code_verifier, opts \\ []) do 216 + opts = Keyword.validate!(opts, [:key, :client_id, :redirect_uri, :scopes]) 217 + 218 + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 219 + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 220 + redirect_uri = Keyword.get_lazy(opts, :redirect_uri, &Config.redirect_uri/0) 221 + 222 + client_assertion = create_client_assertion(key, client_id, authz_metadata.issuer) 223 + 224 + body = %{ 225 + grant_type: "authorization_code", 226 + client_id: client_id, 227 + redirect_uri: redirect_uri, 228 + code: code, 229 + code_verifier: code_verifier 230 + } 231 + 232 + body = 233 + if Config.is_localhost(), 234 + do: body, 235 + else: 236 + Map.merge(body, %{ 237 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 238 + client_assertion: client_assertion 239 + }) 240 + 241 + Req.new(method: :post, url: authz_metadata.token_endpoint, form: body) 242 + |> DPoP.send_oauth_dpop_request(dpop_key) 243 + |> case do 244 + {:ok, 245 + %{ 246 + "access_token" => access_token, 247 + "refresh_token" => refresh_token, 248 + "expires_in" => expires_in, 249 + "sub" => did 250 + }, nonce} -> 251 + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 252 + 253 + {:ok, 254 + %{ 255 + access_token: access_token, 256 + refresh_token: refresh_token, 257 + did: did, 258 + expires_at: expires_at 259 + }, nonce} 260 + 261 + {:error, reason, _nonce} -> 262 + {:error, reason} 263 + end 264 + end 265 + 266 + @doc """ 267 + Refresh an existing set of OAuth tokens. 268 + 269 + Submits the refresh token to the token endpoint using DPoP authentication and 270 + a client assertion. Returns the new token set with an updated DPoP nonce. 271 + 272 + ## Parameters 273 + 274 + - `refresh_token` - The refresh token to exchange 275 + - `dpop_key` - JWK for DPoP token generation 276 + - `issuer` - Authorization server issuer URL (for client assertion `aud`) 277 + - `token_endpoint` - Token endpoint URL 278 + - `opts` - Optional overrides for `:key`, `:client_id` 279 + """ 280 + @spec refresh_token( 281 + String.t(), 282 + JOSE.JWK.t(), 283 + String.t(), 284 + String.t(), 285 + list(refresh_token_option()) 286 + ) :: {:ok, tokens(), String.t() | nil} | {:error, any()} 287 + def refresh_token(refresh_token, dpop_key, issuer, token_endpoint, opts \\ []) do 288 + opts = Keyword.validate!(opts, [:key, :client_id]) 289 + 290 + key = Keyword.get_lazy(opts, :key, &Config.get_key/0) 291 + client_id = Keyword.get_lazy(opts, :client_id, &Config.client_id/0) 292 + 293 + client_assertion = create_client_assertion(key, client_id, issuer) 294 + 295 + body = %{ 296 + grant_type: "refresh_token", 297 + refresh_token: refresh_token, 298 + client_id: client_id, 299 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 300 + client_assertion: client_assertion 301 + } 302 + 303 + Req.new(method: :post, url: token_endpoint, form: body) 304 + |> DPoP.send_oauth_dpop_request(dpop_key) 305 + |> case do 306 + {:ok, 307 + %{ 308 + "access_token" => access_token, 309 + "refresh_token" => refresh_token, 310 + "expires_in" => expires_in, 311 + "sub" => did 312 + }, nonce} -> 313 + expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second) 314 + 315 + {:ok, 316 + %{ 317 + access_token: access_token, 318 + refresh_token: refresh_token, 319 + did: did, 320 + expires_at: expires_at 321 + }, nonce} 322 + 323 + {:error, reason, _nonce} -> 324 + {:error, reason} 325 + end 326 + end 327 + 328 + @doc """ 329 + Revokes the access and refresh tokens with the authorization server. 330 + 331 + Sends the refresh token to the revocation endpoint as defined in RFC 7009. 332 + Token revocation failures are logged as warnings rather than returned as 333 + errors, since the primary goal (ending the session) is still achieved. 334 + 335 + ## Parameters 336 + 337 + - `session` - The session containing tokens to revoke 338 + - `authz_metadata` - Authorization server metadata including `revocation_endpoint` 339 + 340 + ## Returns 341 + 342 + - `:ok` - Tokens revoked (or revocation endpoint unreachable - logged, not raised) 343 + """ 344 + @spec revoke_tokens(Session.t(), authorization_metadata()) :: :ok 345 + def revoke_tokens(%Session{} = session, authz_metadata) do 346 + client_id = Config.client_id() 347 + 348 + body = %{ 349 + client_id: client_id, 350 + token: session.refresh_token, 351 + token_type_hint: "refresh_token" 352 + } 353 + 354 + case Req.post(authz_metadata.revocation_endpoint, form: body) do 355 + {:ok, %{status: status}} when status in [200, 204] -> 356 + :ok 357 + 358 + {:ok, %{body: %{"error" => error}}} -> 359 + Logger.warning("Token revocation failed: #{error}") 360 + :ok 361 + 362 + {:error, reason} -> 363 + Logger.warning("Token revocation request failed: #{inspect(reason)}") 364 + :ok 365 + 366 + unexpected -> 367 + Logger.warning("Unexpected token revocation response: #{inspect(unexpected)}") 368 + :ok 369 + end 370 + end 371 + 372 + @spec random_b64(integer()) :: String.t() 373 + defp random_b64(length) do 374 + :crypto.strong_rand_bytes(length) 375 + |> Base.url_encode64(padding: false) 376 + end 377 + end
+16 -17
lib/atex/oauth/plug.ex
··· 115 115 use Plug.Router 116 116 require Plug.Router 117 117 alias Atex.{DID, IdentityResolver, OAuth} 118 + alias Atex.OAuth.{Discovery, Flow} 118 119 119 120 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 120 - @session_keys_name :atex_sessions 121 - @session_active_name :atex_active_session 122 121 123 122 def init(opts) do 124 123 callback = Keyword.get(opts, :callback, nil) ··· 158 157 case IdentityResolver.resolve(handle) do 159 158 {:ok, identity} -> 160 159 pds = DID.Document.get_pds_endpoint(identity.document) 161 - {:ok, authz_server} = OAuth.get_authorization_server(pds) 162 - {:ok, authz_metadata} = OAuth.get_authorization_server_metadata(authz_server) 160 + {:ok, authz_server} = Discovery.get_authorization_server(pds) 161 + {:ok, authz_metadata} = Discovery.get_authorization_server_metadata(authz_server) 163 162 state = OAuth.create_nonce() 164 163 code_verifier = OAuth.create_nonce() 165 164 166 - case OAuth.create_authorization_url( 165 + case Flow.create_authorization_url( 167 166 authz_metadata, 168 167 state, 169 168 code_verifier, ··· 191 190 get "/client-metadata.json" do 192 191 conn 193 192 |> put_resp_content_type("application/json") 194 - |> send_resp(200, JSON.encode_to_iodata!(OAuth.create_client_metadata())) 193 + |> send_resp(200, JSON.encode_to_iodata!(Flow.create_client_metadata())) 195 194 end 196 195 197 196 get "/callback" do ··· 212 211 reason: :invalid_callback_request 213 212 end 214 213 215 - with {:ok, authz_metadata} <- OAuth.get_authorization_server_metadata(stored_issuer), 214 + with {:ok, authz_metadata} <- Discovery.get_authorization_server_metadata(stored_issuer), 216 215 dpop_key <- JOSE.JWK.generate_key({:ec, "P-256"}), 217 216 {:ok, tokens, dpop_nonce} <- 218 - OAuth.validate_authorization_code( 217 + Flow.validate_authorization_code( 219 218 authz_metadata, 220 219 dpop_key, 221 220 code, ··· 224 223 {:ok, identity} <- IdentityResolver.resolve(tokens.did), 225 224 # Make sure pds' issuer matches the stored one (just in case) 226 225 pds <- DID.Document.get_pds_endpoint(identity.document), 227 - {:ok, authz_server} <- OAuth.get_authorization_server(pds), 226 + {:ok, authz_server} <- Discovery.get_authorization_server(pds), 228 227 true <- authz_server == stored_issuer do 229 228 device_nonce = OAuth.create_nonce() 230 229 ··· 244 243 245 244 case OAuth.SessionStore.insert(session) do 246 245 :ok -> 247 - existing_keys = get_session(conn, @session_keys_name) || [] 246 + existing_keys = get_session(conn, OAuth.session_keys_name()) || [] 248 247 249 248 conn = 250 249 conn 251 250 |> delete_resp_cookie("state", @oauth_cookie_opts) 252 251 |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 253 252 |> delete_resp_cookie("issuer", @oauth_cookie_opts) 254 - |> put_session(@session_keys_name, [session_key | existing_keys]) 255 - |> put_session(@session_active_name, session_key) 253 + |> put_session(OAuth.session_keys_name(), [session_key | existing_keys]) 254 + |> put_session(OAuth.session_active_session_name(), session_key) 256 255 257 256 {mod, func, args} = callback 258 257 apply(mod, func, [conn | args]) ··· 327 326 def revoke_session(%Plug.Conn{} = conn, session_key) do 328 327 case OAuth.delete_session(session_key) do 329 328 :ok -> 330 - session_keys = get_session(conn, @session_keys_name) || [] 331 - active_key = get_session(conn, @session_active_name) 329 + session_keys = get_session(conn, OAuth.session_keys_name()) || [] 330 + active_key = get_session(conn, OAuth.session_active_session_name()) 332 331 333 332 session_keys = List.delete(session_keys, session_key) 334 333 ··· 337 336 new_active = List.first(session_keys) 338 337 339 338 conn 340 - |> put_session(@session_active_name, new_active) 341 - |> put_session(@session_keys_name, session_keys) 339 + |> put_session(OAuth.session_active_session_name(), new_active) 340 + |> put_session(OAuth.session_keys_name(), session_keys) 342 341 else 343 - put_session(conn, @session_keys_name, session_keys) 342 + put_session(conn, OAuth.session_keys_name(), session_keys) 344 343 end 345 344 346 345 {:ok, conn}
+6 -5
lib/atex/xrpc/oauth_client.ex
··· 26 26 """ 27 27 28 28 alias Atex.OAuth 29 + alias Atex.OAuth.{Discovery, DPoP, Flow} 29 30 use TypedStruct 30 31 31 32 @behaviour Atex.XRPC.Client ··· 143 144 @spec do_refresh(t()) :: {:ok, OAuth.Session.t()} | {:error, any()} 144 145 defp do_refresh(%__MODULE__{session_key: session_key}) do 145 146 with {:ok, session} <- OAuth.SessionStore.get(session_key), 146 - {:ok, authz_server} <- OAuth.get_authorization_server(session.aud), 147 + {:ok, authz_server} <- Discovery.get_authorization_server(session.aud), 147 148 {:ok, %{token_endpoint: token_endpoint}} <- 148 - OAuth.get_authorization_server_metadata(authz_server) do 149 - case OAuth.refresh_token( 149 + Discovery.get_authorization_server_metadata(authz_server) do 150 + case Flow.refresh_token( 150 151 session.refresh_token, 151 152 session.dpop_key, 152 153 session.iss, ··· 227 228 |> Req.new() 228 229 |> Req.Request.put_header("authorization", "DPoP #{session.access_token}") 229 230 230 - case OAuth.request_protected_dpop_resource( 231 + case DPoP.request_protected_dpop_resource( 231 232 request, 232 233 session.iss, 233 234 session.access_token, ··· 264 265 if auth_error?(response) do 265 266 case do_refresh(client) do 266 267 {:ok, session} -> 267 - case OAuth.request_protected_dpop_resource( 268 + case DPoP.request_protected_dpop_resource( 268 269 request, 269 270 session.iss, 270 271 session.access_token,
+94
test/atex/oauth/dpop_test.exs
··· 1 + defmodule Atex.OAuth.DPoPTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.OAuth.DPoP 5 + 6 + describe "create_dpop_token/4" do 7 + test "returns a compact JWT string" do 8 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 9 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 10 + 11 + token = DPoP.create_dpop_token(key, request) 12 + 13 + assert is_binary(token) 14 + assert length(String.split(token, ".")) == 3 15 + end 16 + 17 + test "sets htm to uppercased HTTP method" do 18 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 19 + request = Req.new(method: :post, url: "https://example.com/xrpc/foo") 20 + 21 + token = DPoP.create_dpop_token(key, request) 22 + %{fields: claims} = JOSE.JWT.peek(token) 23 + 24 + assert claims["htm"] == "POST" 25 + end 26 + 27 + test "sets htu to URL without query string" do 28 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 29 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo?bar=baz") 30 + 31 + token = DPoP.create_dpop_token(key, request) 32 + %{fields: claims} = JOSE.JWT.peek(token) 33 + 34 + assert claims["htu"] == "https://example.com/xrpc/foo" 35 + end 36 + 37 + test "includes nonce claim when provided" do 38 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 39 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 40 + 41 + token = DPoP.create_dpop_token(key, request, "my-server-nonce") 42 + %{fields: claims} = JOSE.JWT.peek(token) 43 + 44 + assert claims["nonce"] == "my-server-nonce" 45 + end 46 + 47 + test "omits nonce claim when nil" do 48 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 49 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 50 + 51 + token = DPoP.create_dpop_token(key, request, nil) 52 + %{fields: claims} = JOSE.JWT.peek(token) 53 + 54 + refute Map.has_key?(claims, "nonce") 55 + end 56 + 57 + test "merges extra claims into the JWT" do 58 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 59 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 60 + 61 + token = 62 + DPoP.create_dpop_token(key, request, nil, %{iss: "https://bsky.social", ath: "abc123"}) 63 + 64 + %{fields: claims} = JOSE.JWT.peek(token) 65 + 66 + assert claims["iss"] == "https://bsky.social" 67 + assert claims["ath"] == "abc123" 68 + end 69 + 70 + test "sets jti and iat" do 71 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 72 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 73 + 74 + token = DPoP.create_dpop_token(key, request) 75 + %{fields: claims} = JOSE.JWT.peek(token) 76 + 77 + assert is_binary(claims["jti"]) 78 + assert String.length(claims["jti"]) > 0 79 + assert is_integer(claims["iat"]) 80 + end 81 + 82 + test "generates unique jti per call" do 83 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 84 + request = Req.new(method: :get, url: "https://example.com/xrpc/foo") 85 + 86 + token1 = DPoP.create_dpop_token(key, request) 87 + token2 = DPoP.create_dpop_token(key, request) 88 + %{fields: claims1} = JOSE.JWT.peek(token1) 89 + %{fields: claims2} = JOSE.JWT.peek(token2) 90 + 91 + refute claims1["jti"] == claims2["jti"] 92 + end 93 + end 94 + end
+84
test/atex/oauth/flow_test.exs
··· 1 + defmodule Atex.OAuth.FlowTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.OAuth.Flow 5 + 6 + describe "create_client_assertion/3" do 7 + setup do 8 + key = JOSE.JWK.generate_key({:ec, "P-256"}) 9 + key = %{key | fields: Map.put(key.fields, "kid", "test-kid-123")} 10 + %{key: key} 11 + end 12 + 13 + test "returns a compact JWT string", %{key: key} do 14 + token = 15 + Flow.create_client_assertion( 16 + key, 17 + "https://example.com/client-metadata.json", 18 + "https://bsky.social" 19 + ) 20 + 21 + assert is_binary(token) 22 + assert length(String.split(token, ".")) == 3 23 + end 24 + 25 + test "sets iss and sub to client_id", %{key: key} do 26 + client_id = "https://example.com/client-metadata.json" 27 + token = Flow.create_client_assertion(key, client_id, "https://bsky.social") 28 + 29 + %{fields: claims} = JOSE.JWT.peek(token) 30 + 31 + assert claims["iss"] == client_id 32 + assert claims["sub"] == client_id 33 + end 34 + 35 + test "sets aud to issuer", %{key: key} do 36 + issuer = "https://bsky.social" 37 + 38 + token = 39 + Flow.create_client_assertion(key, "https://example.com/client-metadata.json", issuer) 40 + 41 + %{fields: claims} = JOSE.JWT.peek(token) 42 + 43 + assert claims["aud"] == issuer 44 + end 45 + 46 + test "expires 60 seconds after iat", %{key: key} do 47 + token = 48 + Flow.create_client_assertion( 49 + key, 50 + "https://example.com/client-metadata.json", 51 + "https://bsky.social" 52 + ) 53 + 54 + %{fields: claims} = JOSE.JWT.peek(token) 55 + 56 + assert claims["exp"] - claims["iat"] == 60 57 + end 58 + 59 + test "sets a non-empty jti", %{key: key} do 60 + token = 61 + Flow.create_client_assertion( 62 + key, 63 + "https://example.com/client-metadata.json", 64 + "https://bsky.social" 65 + ) 66 + 67 + %{fields: claims} = JOSE.JWT.peek(token) 68 + 69 + assert is_binary(claims["jti"]) 70 + assert String.length(claims["jti"]) > 0 71 + end 72 + 73 + test "produces a validly signed JWT", %{key: key} do 74 + token = 75 + Flow.create_client_assertion( 76 + key, 77 + "https://example.com/client-metadata.json", 78 + "https://bsky.social" 79 + ) 80 + 81 + {true, %JOSE.JWT{}, _} = JOSE.JWT.verify(JOSE.JWK.to_public(key), token) 82 + end 83 + end 84 + end
+27
test/atex/oauth_test.exs
··· 1 + defmodule Atex.OAuthTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atex.OAuth 5 + 6 + describe "create_nonce/0" do 7 + test "returns a binary" do 8 + assert is_binary(OAuth.create_nonce()) 9 + end 10 + 11 + test "returns unique values on each call" do 12 + refute OAuth.create_nonce() == OAuth.create_nonce() 13 + end 14 + end 15 + 16 + describe "session_keys_name/0" do 17 + test "returns the session keys atom" do 18 + assert OAuth.session_keys_name() == :atex_sessions 19 + end 20 + end 21 + 22 + describe "session_active_session_name/0" do 23 + test "returns the active session atom" do 24 + assert OAuth.session_active_session_name() == :atex_active_session 25 + end 26 + end 27 + end