diff --git a/TODO.md b/TODO.md index cb278a2..46af8f9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,5 @@ # Parking Lot (TODO) -- Add OAuth loopback development client metadata: - - support the `http://localhost` client ID profile used by AT Protocol OAuth - development clients - - synthesize metadata from `redirect_uri` and `scope` query parameters - - restrict loopback redirect URIs to HTTP loopback hosts - - keep non-loopback clients on HTTPS metadata documents - - Add OAuth private-use redirect scheme support for native clients: - accept reverse-domain private-use schemes only for native clients - reject credentials, hosts, ports, fragments, and local/reserved scheme roots diff --git a/docs/specs/README.md b/docs/specs/README.md index 4d7fa12..e304498 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -34,6 +34,7 @@ Subsystem specifications live in this directory. 18. [Documentation Viewer](./doc-viewer.md) 19. [Account Management Control Panel](./account-management.md) 20. [Password Resets and Resend Email Delivery](./password-resets-resend-email.md) +21. [Admin XRPC Methods](./admin-xrpc.md) ## Source Baseline diff --git a/docs/specs/admin-xrpc.md b/docs/specs/admin-xrpc.md new file mode 100644 index 0000000..bf769fd --- /dev/null +++ b/docs/specs/admin-xrpc.md @@ -0,0 +1,132 @@ +--- +title: Admin XRPC Methods +updated: 2026-06-19 +status: planned +--- + +Tempest already has Phoenix operator pages and internal admin helpers. This +spec covers a separate compatibility layer for the official +`com.atproto.admin.*` XRPC namespace after those Lexicons are vendored. + +## Goals + +- Vendor the official `com.atproto.admin.*` Lexicons from + `bluesky-social/atproto` at the same pinned revision as the rest of + `priv/lexicons/official`. +- Expose admin XRPC methods only behind the existing admin-token trust boundary. +- Prefer read-only account inspection first, then status and invite controls, + then destructive account mutation only after audit logging and tests are in + place. +- Keep operator UI behavior and XRPC admin behavior backed by the same domain + functions where practical. + +## Non-goals + +- Do not make account bearer tokens valid for `com.atproto.admin.*`. +- Do not treat vendored Lexicons as automatic behavior. Registration and handler + implementation remain explicit. +- Do not implement destructive methods without audit events and integration + coverage. + +## Lexicons + +The official admin namespace currently contains these method documents: + +- `com.atproto.admin.getAccountInfo` +- `com.atproto.admin.getAccountInfos` +- `com.atproto.admin.searchAccounts` +- `com.atproto.admin.deleteAccount` +- `com.atproto.admin.updateAccountEmail` +- `com.atproto.admin.updateAccountHandle` +- `com.atproto.admin.updateAccountPassword` +- `com.atproto.admin.updateAccountSigningKey` +- `com.atproto.admin.getInviteCodes` +- `com.atproto.admin.disableInviteCodes` +- `com.atproto.admin.disableAccountInvites` +- `com.atproto.admin.enableAccountInvites` +- `com.atproto.admin.getSubjectStatus` +- `com.atproto.admin.updateSubjectStatus` +- `com.atproto.admin.sendEmail` +- `com.atproto.admin.defs` + +Required dependencies include existing `com.atproto.repo.strongRef` and +`com.atproto.server.defs#inviteCode`. + +## Auth + +Register admin methods with `auth: :admin` in `Tempest.Xrpc.Registry`. +`TempestWeb.Plugs.XrpcAuth` currently handles bearer account auth only, so it +needs an admin branch that verifies `Authorization: Bearer ` with +`Tempest.AdminAuth.verify_authorization_header/1`. + +Admin failures should return normal XRPC JSON errors: + +- missing token: `401 AuthenticationRequired` +- invalid token: `401 InvalidToken` +- admin token not configured: `503 ServiceUnavailable` or `403 Forbidden` + +The exact unconfigured status should be decided once the handler branch is +implemented. The important property is that admin methods never silently fall +back to account auth. + +## Initial Method Set + +Start with read-only methods: + +- `getAccountInfo`: find one local account by DID and return + `com.atproto.admin.defs#accountView`. +- `getAccountInfos`: return the same shape for a list of DIDs. +- `searchAccounts`: support email search first, then add handle/DID search if + the official shape remains compatible. + +Next implement status and invite controls: + +- `getSubjectStatus` and `updateSubjectStatus` for repo/account subjects first. +- Blob and record subject status after a small persistence model exists. +- `getInviteCodes`, `disableInviteCodes`, `disableAccountInvites`, and + `enableAccountInvites` only if Tempest keeps invite-code state compatible + with `com.atproto.server.defs#inviteCode`. + +Leave destructive account mutation last: + +- `deleteAccount` +- `updateAccountEmail` +- `updateAccountHandle` +- `updateAccountPassword` +- `updateAccountSigningKey` +- `sendEmail` + +## Persistence + +Account-level deactivation already exists in account state. Takedown and +subject-status support needs explicit persistence if it should apply to records +and blobs. A simple first model is: + +- subject kind: `account`, `record`, or `blob` +- DID +- optional record URI +- optional CID +- takedown applied flag and reference string +- deactivated applied flag and reference string +- timestamps and actor metadata for audit + +Use this model to drive both admin XRPC responses and any future operator UI. + +## Verification + +Required tests before marking implemented: + +```bash +mix test test/tempest_web/xrpc/admin_test.exs +mix test test/tempest/lexicon +mix precommit +``` + +Add a Hurl smoke test once at least one read-only method is exposed through a +running server: + +```bash +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/admin-xrpc.hurl +``` diff --git a/docs/tasks/20-admin-xrpc.md b/docs/tasks/20-admin-xrpc.md new file mode 100644 index 0000000..010108b --- /dev/null +++ b/docs/tasks/20-admin-xrpc.md @@ -0,0 +1,47 @@ +--- +title: Milestone 20 - Admin XRPC Methods +specs: + - ../specs/admin-xrpc.md + - ../specs/admin-operations.md + - ../specs/xrpc.md + - ../specs/lexicon-schemas.md +--- + +Status: planned. + +## Tasks + +- [ ] Vendor `com/atproto/admin/*.json` into `priv/lexicons/official`. +- [ ] Regenerate `Tempest.Lexicon.Bundled` and confirm admin document IDs appear + in the manifest. +- [ ] Add `auth: :admin` handling to the XRPC auth plug without changing bearer + account-token behavior. +- [ ] Register read-only admin methods in `Tempest.Xrpc.Registry`: + `getAccountInfo`, `getAccountInfos`, and `searchAccounts`. +- [ ] Implement read-only handlers against local account data. +- [ ] Add XRPC integration tests for missing admin token, invalid admin token, + account token rejection, and successful admin-token requests. +- [ ] Add subject-status persistence before implementing + `getSubjectStatus`/`updateSubjectStatus`. +- [ ] Implement invite admin methods only if invite-code persistence is present + and matches the official response shape. +- [ ] Implement destructive account mutations last, with audit events and focused + tests for each method. +- [ ] Add a running-server Hurl smoke test once a stable read-only endpoint + exists. + +## Verification + +```bash +mix test test/tempest_web/xrpc/admin_test.exs +mix test test/tempest/lexicon +mix precommit +``` + +For running-server coverage: + +```bash +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/admin-xrpc.hurl +``` diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5612c91..6360b5d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -24,6 +24,7 @@ title: Milestone Tasks 18. [Doc Viewer](./17-doc-viewer.md) 19. [Account Management Control Panel](./18-account-management.md) 20. [Password Resets and Resend Email Delivery](./19-password-resets-resend-email.md) +21. [Admin XRPC Methods](./20-admin-xrpc.md) Each file in this directory is a milestone. Each task is intended to be the smallest useful unit of work: one focused implementation change, test, or diff --git a/lib/tempest/accounts/tokens.ex b/lib/tempest/accounts/tokens.ex index 02cce59..1efca86 100644 --- a/lib/tempest/accounts/tokens.ex +++ b/lib/tempest/accounts/tokens.ex @@ -15,6 +15,9 @@ defmodule Tempest.Accounts.Tokens do @refresh_lifetime_seconds 60 * 60 * 24 * 30 @refresh_prefix "tempest-refresh-v1." + @doc """ + Signs the AT Protocol session access JWT for a local account session. + """ def sign_access_token(%Account{} = account, %Session{} = session) do sign_session_jwt!(account, %{ "typ" => "access", @@ -27,6 +30,9 @@ defmodule Tempest.Accounts.Tokens do }) end + @doc """ + Signs a long-lived refresh JWT for a refresh-token family. + """ def sign_refresh_token(%Account{} = account, family_id) when is_binary(family_id) do sign_session_jwt!(account, %{ "typ" => "refresh", @@ -37,6 +43,9 @@ defmodule Tempest.Accounts.Tokens do }) end + @doc """ + Signs the legacy Phoenix-token access format kept for compatibility. + """ def sign_legacy_access_token(%Account{} = account, %Session{} = session) do Phoenix.Token.sign(Endpoint, @access_salt, %{ "typ" => "access", @@ -46,6 +55,9 @@ defmodule Tempest.Accounts.Tokens do }) end + @doc """ + Verifies either the current session access JWT or the legacy Phoenix-token format. + """ def verify_access_token(token) when is_binary(token) do case verify_access_session_jwt(token) do {:ok, claims} -> {:ok, claims} @@ -55,6 +67,12 @@ defmodule Tempest.Accounts.Tokens do def verify_access_token(_token), do: {:error, :invalid} + @doc """ + Signs an inter-service auth JWT scoped to one XRPC method. + + The token uses the account repo signing key and includes `lxm` for the target + method NSID plus `aud` for the target service DID. + """ def sign_service_auth(%Account{} = account, audience, method_nsid) when is_binary(audience) and is_binary(method_nsid) do now = DateTime.utc_now() |> DateTime.to_unix() @@ -76,6 +94,9 @@ defmodule Tempest.Accounts.Tokens do compact end + @doc """ + Verifies an inter-service auth JWT against the issuer's current DID document. + """ def verify_service_auth(token) when is_binary(token) do with {:ok, header} <- peek_service_auth_header(token), :ok <- validate_service_auth_header(header), @@ -208,15 +229,24 @@ defmodule Tempest.Accounts.Tokens do defp public_jwk_from_raw_secp256k1(_public_key), do: {:error, :invalid} + @doc """ + Generates an opaque refresh token suitable for database hashing. + """ def new_refresh_token do @refresh_prefix <> random_url_token(48) end + @doc """ + Hashes a refresh token for storage and lookup. + """ def refresh_token_hash(token) when is_binary(token) do :crypto.hash(:sha256, token) |> Base.encode16(case: :lower) end + @doc """ + Returns the refresh token expiry timestamp for a given issue time. + """ def refresh_expires_at(now \\ DateTime.utc_now()) do now |> DateTime.add(@refresh_lifetime_seconds, :second) diff --git a/lib/tempest/admin_auth.ex b/lib/tempest/admin_auth.ex index a4f7cdb..75e3e35 100644 --- a/lib/tempest/admin_auth.ex +++ b/lib/tempest/admin_auth.ex @@ -48,6 +48,9 @@ defmodule Tempest.AdminAuth do end end + @doc """ + Returns true when an admin token hash is configured. + """ def configured?, do: is_binary(configured_hash()) and configured_hash() != "" defp configured_hash do diff --git a/lib/tempest/oauth.ex b/lib/tempest/oauth.ex index 74bd892..dcc5c52 100644 --- a/lib/tempest/oauth.ex +++ b/lib/tempest/oauth.ex @@ -17,8 +17,18 @@ defmodule Tempest.OAuth do @supported_scopes ~w(atproto transition:generic transition:chat.bsky transition:email blob:*/* rpc:*) + @doc """ + Returns the OAuth scopes this authorization server accepts in PAR requests. + """ def supported_scopes, do: @supported_scopes + @doc """ + Creates a pushed authorization request after validating client metadata, PKCE, + requested scopes, DPoP, and any required client authentication. + + The returned `%ParRequest{}` stores the DPoP key thumbprint and client-auth + binding that later token requests must prove again. + """ def create_par(params, dpop_proof, public_url) do with :ok <- require_param(params, "client_id"), :ok <- require_param(params, "redirect_uri"), @@ -60,6 +70,9 @@ defmodule Tempest.OAuth do end end + @doc """ + Fetches an unused, unexpired pushed authorization request by `request_uri`. + """ def get_valid_par(request_uri) when is_binary(request_uri) do now = now() @@ -76,6 +89,12 @@ defmodule Tempest.OAuth do def get_valid_par(_request_uri), do: {:error, :invalid_request_uri} + @doc """ + Authenticates the resource owner for a PAR request and issues an authorization code. + + This marks the PAR as used in the same transaction that creates the code, so a + request URI cannot be replayed into multiple codes. + """ def authorize(params) do with {:ok, par} <- get_valid_par(Map.get(params, "request_uri")), {:ok, account} <- authenticate_account(Map.get(params, "identifier"), Map.get(params, "password")) do @@ -117,6 +136,12 @@ defmodule Tempest.OAuth do end end + @doc """ + Exchanges an authorization code for DPoP-bound access and refresh tokens. + + The function verifies client identity, client authentication, redirect URI, + PKCE, and the original DPoP key binding before issuing tokens. + """ def exchange_authorization_code(params, dpop_proof, public_url) do with :ok <- require_param(params, "code"), :ok <- require_param(params, "client_id"), @@ -132,6 +157,9 @@ defmodule Tempest.OAuth do end end + @doc """ + Rotates an OAuth refresh token and returns a fresh DPoP-bound token pair. + """ def refresh(params, dpop_proof, public_url) do with :ok <- require_param(params, "refresh_token"), :ok <- require_param(params, "client_id"), @@ -143,6 +171,9 @@ defmodule Tempest.OAuth do end end + @doc """ + Revokes any OAuth access or refresh token matching the provided token string. + """ def revoke(token_string) when is_binary(token_string) do now = now() access_hash = hash(token_string) @@ -155,6 +186,9 @@ defmodule Tempest.OAuth do :ok end + @doc """ + Signs a short-lived OAuth access token for an issued OAuth token row. + """ def sign_access_token(%Account{} = account, %Token{} = token) do Phoenix.Token.sign(Endpoint, @access_salt, %{ "typ" => "oauth_access", @@ -167,6 +201,12 @@ defmodule Tempest.OAuth do }) end + @doc """ + Verifies an OAuth access token and returns the account, token row, and claims. + + Validation checks the Phoenix signature, backing token row, token hash, + expiration, revocation state, and local account correctness. + """ def verify_access_token(token) when is_binary(token) do with {:ok, %{"typ" => "oauth_access", "account_id" => account_id, "token_id" => token_id} = claims} <- Phoenix.Token.verify(Endpoint, @access_salt, token, max_age: @access_lifetime_seconds), diff --git a/lib/tempest/oauth/client_assertion_verifier.ex b/lib/tempest/oauth/client_assertion_verifier.ex index def8130..a6b97c4 100644 --- a/lib/tempest/oauth/client_assertion_verifier.ex +++ b/lib/tempest/oauth/client_assertion_verifier.ex @@ -11,6 +11,13 @@ defmodule Tempest.OAuth.ClientAssertionVerifier do @max_lifetime_seconds 5 * 60 @max_iat_skew_seconds 60 + @doc """ + Verifies the client authentication required by a client metadata document. + + Public clients using `token_endpoint_auth_method: "none"` return `{:ok, nil}`. + Confidential clients must present a non-replayed ES256 `private_key_jwt` + assertion signed by a key from the client's JWKS. + """ def verify(%ClientMetadata{token_endpoint_auth_method: "none"}, _params, _issuer), do: {:ok, nil} def verify(%ClientMetadata{token_endpoint_auth_method: "private_key_jwt"} = client, params, issuer) diff --git a/lib/tempest/oauth/client_metadata.ex b/lib/tempest/oauth/client_metadata.ex index fd74a4d..598c6ab 100644 --- a/lib/tempest/oauth/client_metadata.ex +++ b/lib/tempest/oauth/client_metadata.ex @@ -10,6 +10,7 @@ defmodule Tempest.OAuth.ClientMetadata do alias Tempest.Security.ExternalMetadataFetcher @max_body_bytes 64 * 1024 + @default_loopback_redirect_uris ["http://127.0.0.1/", "http://[::1]/"] @type t :: %__MODULE__{ client_id: String.t(), @@ -40,9 +41,9 @@ defmodule Tempest.OAuth.ClientMetadata do with {:ok, client_id} <- fetch_param(params, "client_id"), {:ok, redirect_uri} <- fetch_param(params, "redirect_uri"), {:ok, requested_scope} <- fetch_param(params, "scope"), - :ok <- validate_client_id_url(client_id), - {:ok, metadata} <- fetch_metadata(client_id), - {:ok, client} <- parse_metadata(metadata, client_id), + {:ok, client_type} <- validate_client_id_url(client_id), + {:ok, metadata} <- fetch_metadata(client_id, client_type), + {:ok, client} <- parse_metadata(metadata, client_id, client_type), :ok <- validate_redirect_uri(client, redirect_uri), :ok <- validate_requested_scope(client, requested_scope) do {:ok, client} @@ -56,16 +57,16 @@ defmodule Tempest.OAuth.ClientMetadata do """ @spec fetch(String.t()) :: {:ok, t()} | {:error, atom()} def fetch(client_id) when is_binary(client_id) do - with :ok <- validate_client_id_url(client_id), - {:ok, metadata} <- fetch_metadata(client_id), - {:ok, client} <- parse_metadata(metadata, client_id) do + with {:ok, client_type} <- validate_client_id_url(client_id), + {:ok, metadata} <- fetch_metadata(client_id, client_type), + {:ok, client} <- parse_metadata(metadata, client_id, client_type) do {:ok, client} end end def fetch(_client_id), do: {:error, :invalid_client} - defp fetch_metadata(client_id) do + defp fetch_metadata(client_id, :https_metadata) do case ExternalMetadataFetcher.fetch_json(client_id, max_body_bytes: @max_body_bytes) do {:ok, metadata} when is_map(metadata) -> {:ok, metadata} {:ok, _metadata} -> {:error, :invalid_client} @@ -73,10 +74,14 @@ defmodule Tempest.OAuth.ClientMetadata do end end - defp parse_metadata(metadata, client_id) do + defp fetch_metadata(client_id, :localhost_development) do + synthesize_loopback_metadata(client_id) + end + + defp parse_metadata(metadata, client_id, client_type) do with ^client_id <- Map.get(metadata, "client_id"), redirect_uris when is_list(redirect_uris) <- Map.get(metadata, "redirect_uris"), - true <- Enum.all?(redirect_uris, &valid_redirect_uri?/1), + true <- Enum.all?(redirect_uris, &valid_redirect_uri?(&1, client_type)), true <- contains_string?(Map.get(metadata, "response_types"), "code"), true <- contains_string?(Map.get(metadata, "grant_types"), "authorization_code"), auth_method when auth_method in ["none", "private_key_jwt"] <- @@ -101,19 +106,34 @@ defmodule Tempest.OAuth.ClientMetadata do defp validate_client_id_url(client_id) do case URI.parse(client_id) do - %URI{scheme: "https", host: host, userinfo: nil, fragment: nil} when is_binary(host) -> :ok - _uri -> {:error, :invalid_client} + %URI{scheme: "https", host: host, userinfo: nil, fragment: nil} when is_binary(host) -> + {:ok, :https_metadata} + + %URI{scheme: "http", authority: "localhost", host: "localhost", path: path, userinfo: nil, fragment: nil} + when path in [nil, "", "/"] -> + {:ok, :localhost_development} + + _uri -> + {:error, :invalid_client} end end - defp validate_redirect_uri(%__MODULE__{redirect_uris: redirect_uris}, redirect_uri) do - if redirect_uri in redirect_uris do + defp validate_redirect_uri( + %__MODULE__{client_id: "http://localhost" <> _rest, redirect_uris: redirect_uris}, + redirect_uri + ) do + if valid_loopback_redirect_uri?(redirect_uri) and + Enum.any?(redirect_uris, &same_loopback_redirect?(&1, redirect_uri)) do :ok else {:error, :invalid_request} end end + defp validate_redirect_uri(%__MODULE__{redirect_uris: redirect_uris}, redirect_uri) do + if redirect_uri in redirect_uris, do: :ok, else: {:error, :invalid_request} + end + defp validate_requested_scope(%__MODULE__{scope: nil}, _requested_scope), do: :ok defp validate_requested_scope(%__MODULE__{scope: client_scope}, requested_scope) when is_binary(client_scope) do @@ -153,7 +173,7 @@ defmodule Tempest.OAuth.ClientMetadata do {nil, jwks_uri} when is_binary(jwks_uri) -> with :ok <- validate_jwks_uri(jwks_uri), - {:ok, jwks} <- fetch_metadata(jwks_uri) do + {:ok, jwks} <- fetch_metadata(jwks_uri, :https_metadata) do validate_jwks_object(jwks) end @@ -210,12 +230,73 @@ defmodule Tempest.OAuth.ClientMetadata do defp contains_string?(values, value) when is_list(values), do: value in values defp contains_string?(_values, _value), do: false - defp valid_redirect_uri?(uri) when is_binary(uri) do + defp synthesize_loopback_metadata(client_id) do + uri = URI.parse(client_id) + query_params = URI.query_decoder(uri.query || "") |> Enum.to_list() + redirect_uris = query_values(query_params, "redirect_uri") + scopes = query_values(query_params, "scope") + + with true <- length(scopes) <= 1, + scope <- List.first(scopes) || "atproto", + true <- scope != "" do + {:ok, + %{ + "client_id" => client_id, + "client_name" => "Development client", + "redirect_uris" => default_if_empty(redirect_uris, @default_loopback_redirect_uris), + "grant_types" => ["authorization_code", "refresh_token"], + "response_types" => ["code"], + "scope" => scope, + "token_endpoint_auth_method" => "none", + "application_type" => "native", + "dpop_bound_access_tokens" => true + }} + else + _reason -> {:error, :invalid_client} + end + end + + defp query_values(query_params, key) do + query_params + |> Enum.filter(fn {param_key, _value} -> param_key == key end) + |> Enum.map(fn {_param_key, value} -> value end) + end + + defp default_if_empty([], default), do: default + defp default_if_empty(values, _default), do: values + + defp valid_redirect_uri?(uri, :https_metadata), do: valid_https_redirect_uri?(uri) + defp valid_redirect_uri?(uri, :localhost_development), do: valid_loopback_redirect_uri?(uri) + + defp valid_https_redirect_uri?(uri) when is_binary(uri) do case URI.parse(uri) do %URI{scheme: "https", host: host, fragment: nil} when is_binary(host) -> true _uri -> false end end - defp valid_redirect_uri?(_uri), do: false + defp valid_https_redirect_uri?(_uri), do: false + + defp valid_loopback_redirect_uri?(uri) when is_binary(uri) do + case URI.parse(uri) do + %URI{scheme: "http", host: host, userinfo: nil, fragment: nil} when host in ["localhost", "127.0.0.1", "::1"] -> + true + + _uri -> + false + end + end + + defp valid_loopback_redirect_uri?(_uri), do: false + + defp same_loopback_redirect?(registered_uri, requested_uri) do + registered = URI.parse(registered_uri) + requested = URI.parse(requested_uri) + + registered.scheme == requested.scheme and registered.host == requested.host and + normalize_path(registered.path) == normalize_path(requested.path) and registered.query == requested.query + end + + defp normalize_path(path) when path in [nil, ""], do: "/" + defp normalize_path(path), do: path end diff --git a/lib/tempest/oauth/dpop.ex b/lib/tempest/oauth/dpop.ex index 219c3ca..7bc0280 100644 --- a/lib/tempest/oauth/dpop.ex +++ b/lib/tempest/oauth/dpop.ex @@ -17,6 +17,9 @@ defmodule Tempest.OAuth.Dpop do @max_iat_skew_seconds 60 @supported_algs ["ES256", "ES384", "ES512", "RS256", "PS256"] + @doc """ + Creates and stores a single-use DPoP nonce. + """ def issue_nonce do nonce = random_token(32) now = now() @@ -32,6 +35,12 @@ defmodule Tempest.OAuth.Dpop do end end + @doc """ + Verifies a DPoP proof JWT for the expected HTTP method and URL. + + Pass `bound_jkt: thumbprint` when the proof must use the same key as an + already-issued DPoP-bound credential. + """ def verify_proof(proof, method, url, opts \\ []) def verify_proof(nil, _method, _url, _opts), do: {:error, :missing_dpop} @@ -110,6 +119,9 @@ defmodule Tempest.OAuth.Dpop do defp consume_nonce(_nonce), do: {:error, :invalid_dpop_nonce} + @doc """ + Computes the RFC 7638 JWK thumbprint used for DPoP token binding. + """ def jwk_thumbprint(%{"kty" => "EC", "crv" => crv, "x" => x, "y" => y}) do thumbprint(%{"crv" => crv, "kty" => "EC", "x" => x, "y" => y}) end diff --git a/lib/tempest/oauth/metadata.ex b/lib/tempest/oauth/metadata.ex index 5cbd19e..d138e7f 100644 --- a/lib/tempest/oauth/metadata.ex +++ b/lib/tempest/oauth/metadata.ex @@ -12,6 +12,9 @@ defmodule Tempest.OAuth.Metadata do "rpc:*" ] + @doc """ + Builds the OAuth protected resource metadata document. + """ def protected_resource do base_url = base_url() @@ -24,6 +27,9 @@ defmodule Tempest.OAuth.Metadata do } end + @doc """ + Builds the OAuth authorization server metadata document. + """ def authorization_server do base_url = base_url() diff --git a/lib/tempest/security.ex b/lib/tempest/security.ex index f6bfd1c..ffc88b8 100644 --- a/lib/tempest/security.ex +++ b/lib/tempest/security.ex @@ -24,6 +24,9 @@ defmodule Tempest.Security do @email_token_ttl_seconds 30 * 60 @plc_operation_token_ttl_seconds 10 * 60 + @doc """ + Records a security audit event for an account. + """ def log_event(%Account{} = account, event_type, metadata \\ %{}) do attrs = %{ account_id: account.id, @@ -34,6 +37,9 @@ defmodule Tempest.Security do %SecurityEvent{} |> SecurityEvent.changeset(attrs) |> Repo.insert() end + @doc """ + Issues a short-lived email token for confirmation, email update, or password reset flows. + """ def issue_email_token(%Account{} = account, purpose, email \\ nil) do raw = random_token(32) email = email || account.email @@ -53,6 +59,13 @@ defmodule Tempest.Security do end end + @doc """ + Consumes a valid email token for the expected purpose. + + Some purposes have side effects: `update_email` updates the account email and + `reset_password` revokes existing sessions before the caller stores the new + password hash. + """ def consume_email_token(raw, purpose) when is_binary(raw) do now = now() @@ -90,6 +103,9 @@ defmodule Tempest.Security do def consume_email_token(_raw, _purpose), do: {:error, :invalid_token} + @doc """ + Starts TOTP enrollment and returns the plaintext secret plus an otpauth URI. + """ def start_totp_enrollment(%Account{} = account, label \\ nil) do secret = Totp.new_secret() label = label || account.handle @@ -112,6 +128,9 @@ defmodule Tempest.Security do end end + @doc """ + Confirms a pending TOTP credential and rotates backup codes. + """ def confirm_totp(%Account{} = account, credential_id, code) do with %MfaCredential{} = credential <- Repo.get_by(MfaCredential, id: credential_id, account_id: account.id), true <- is_nil(credential.disabled_at), @@ -125,6 +144,9 @@ defmodule Tempest.Security do end end + @doc """ + Verifies a confirmed TOTP code for an account with rate limiting. + """ def verify_totp(%Account{} = account, code) do if RateLimiter.check(:totp, account.did) == :ok and valid_totp_for_account?(account, code) do log_event(account, "mfa.totp.verified", %{}) @@ -134,6 +156,9 @@ defmodule Tempest.Security do end end + @doc """ + Marks one backup code as used if it matches an unused stored hash. + """ def use_backup_code(%Account{} = account, code) do now = now() @@ -150,6 +175,9 @@ defmodule Tempest.Security do end end + @doc """ + Lists account sessions newest first. + """ def list_sessions(%Account{} = account) do Session |> where([s], s.account_id == ^account.id) @@ -157,6 +185,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Revokes one active account session. + """ def revoke_session(%Account{} = account, session_id) do now = now() @@ -173,6 +204,9 @@ defmodule Tempest.Security do end end + @doc """ + Creates a delegated-access grant from an owner account to another DID. + """ def create_delegation(%Account{} = owner, delegate_did, scope, opts \\ []) do attrs = %{ owner_account_id: owner.id, @@ -187,6 +221,9 @@ defmodule Tempest.Security do end end + @doc """ + Revokes an active delegated-access grant owned by the account. + """ def revoke_delegation(%Account{} = owner, grant_id) do now = now() @@ -203,6 +240,9 @@ defmodule Tempest.Security do end end + @doc """ + Returns all security-control records shown by the operator account security UI. + """ def account_security_inventory(%Account{} = account) do %{ sessions: list_sessions(account), @@ -215,6 +255,9 @@ defmodule Tempest.Security do } end + @doc """ + Lists OAuth grants issued to the account. + """ def list_oauth_grants(%Account{} = account) do Token |> where([t], t.account_id == ^account.id) @@ -222,6 +265,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Lists app-password records for the account. + """ def list_app_password_records(%Account{} = account) do AppPassword |> where([p], p.account_id == ^account.id) @@ -229,6 +275,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Lists delegated-access grants owned by the account. + """ def list_delegations(%Account{} = account) do DelegatedAccessGrant |> where([g], g.owner_account_id == ^account.id) @@ -236,6 +285,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Lists MFA credentials for the account. + """ def list_mfa_credentials(%Account{} = account) do MfaCredential |> where([c], c.account_id == ^account.id) @@ -243,6 +295,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Lists backup-code metadata without exposing backup-code plaintext. + """ def list_backup_code_summaries(%Account{} = account) do BackupCode |> where([c], c.account_id == ^account.id) @@ -258,6 +313,9 @@ defmodule Tempest.Security do end) end + @doc """ + Lists recent security events for the account. + """ def list_security_events(%Account{} = account, opts \\ []) do limit = Keyword.get(opts, :limit, 50) @@ -268,6 +326,9 @@ defmodule Tempest.Security do |> Repo.all() end + @doc """ + Verifies an account password using the same constant-time fallback behavior as login. + """ def verify_account_password(%Account{} = account, password) when is_binary(password) do if Password.verify(password, account.password_hash), do: :ok, else: {:error, :invalid_password} end @@ -277,6 +338,9 @@ defmodule Tempest.Security do {:error, :invalid_password} end + @doc """ + Issues a short-lived token that authorizes one PLC operation signature. + """ def issue_plc_operation_token(%Account{} = account) do raw = random_token(32) @@ -292,6 +356,9 @@ defmodule Tempest.Security do end end + @doc """ + Consumes a valid PLC operation token for the account. + """ def consume_plc_operation_token(%Account{} = account, raw) when is_binary(raw) do now = now() @@ -316,6 +383,9 @@ defmodule Tempest.Security do def consume_plc_operation_token(%Account{} = _account, _raw), do: {:error, :invalid_token} + @doc """ + Starts a password reset email flow without revealing whether the identifier exists. + """ def request_password_reset(identifier) do identifier = identifier |> to_string() |> String.trim() |> String.downcase() @@ -326,16 +396,31 @@ defmodule Tempest.Security do end end + @doc """ + Sends an email-confirmation token to the account's current email address. + """ def request_email_confirmation(%Account{} = account), do: Tempest.Security.Email.deliver_confirmation(account) + @doc """ + Confirms the account email associated with an email-confirmation token. + """ def confirm_email(raw_token), do: consume_email_token(raw_token, "confirm_email") + @doc """ + Sends an email-update token to a new email address. + """ def request_email_update(%Account{} = account, new_email) when is_binary(new_email) do Tempest.Security.Email.deliver_update(account, new_email) end + @doc """ + Applies a pending email update token. + """ def update_email(raw_token), do: consume_email_token(raw_token, "update_email") + @doc """ + Resets an account password through a valid reset token. + """ def reset_password(raw_token, new_password) do with :ok <- Password.validate(new_password), {:ok, account} <- consume_email_token(raw_token, "reset_password") do diff --git a/lib/tempest_web/plugs/xrpc_auth.ex b/lib/tempest_web/plugs/xrpc_auth.ex index 13bd85c..39a51f9 100644 --- a/lib/tempest_web/plugs/xrpc_auth.ex +++ b/lib/tempest_web/plugs/xrpc_auth.ex @@ -10,8 +10,15 @@ defmodule TempestWeb.Plugs.XrpcAuth do alias Tempest.Xrpc.Registry alias TempestWeb.XrpcErrorJSON + @doc false def init(opts), do: opts + @doc """ + Authenticates registered bearer-protected XRPC methods and assigns `:auth_context`. + + Public methods and preflight requests pass through unchanged. OAuth access + tokens must include a valid DPoP proof for the current request URL. + """ def call(%{method: "OPTIONS"} = conn, _opts), do: conn def call(%{path_params: %{"method" => method_nsid}} = conn, _opts) do diff --git a/test/tempest/oauth/client_metadata_test.exs b/test/tempest/oauth/client_metadata_test.exs index f1a8a37..0d5a2b3 100644 --- a/test/tempest/oauth/client_metadata_test.exs +++ b/test/tempest/oauth/client_metadata_test.exs @@ -149,6 +149,59 @@ defmodule Tempest.OAuth.ClientMetadataTest do }) end + test "synthesizes metadata for localhost development clients" do + client_id = + "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto%20rpc%3A*" + + assert {:ok, %ClientMetadata{} = client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => client_id, + "redirect_uri" => "http://127.0.0.1:49152/callback", + "scope" => "rpc:com.atproto.server.getSession" + }) + + assert client.client_id == client_id + assert client.redirect_uris == ["http://127.0.0.1/callback"] + assert client.scope == "atproto rpc:*" + assert client.token_endpoint_auth_method == "none" + end + + test "localhost development clients default to loopback callbacks and atproto scope" do + assert {:ok, %ClientMetadata{} = client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => "http://localhost", + "redirect_uri" => "http://[::1]:38291/", + "scope" => "atproto" + }) + + assert client.redirect_uris == ["http://127.0.0.1/", "http://[::1]/"] + assert client.scope == "atproto" + end + + test "rejects localhost development client ids with a port or path" do + for client_id <- ["http://localhost:8080", "http://localhost/oauth/client-metadata.json"] do + assert {:error, :invalid_client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => client_id, + "redirect_uri" => "http://127.0.0.1:49152/", + "scope" => "atproto" + }) + end + end + + test "rejects localhost development redirect uris that are not HTTP loopback hosts" do + for redirect_uri <- ["https://127.0.0.1/callback", "http://example.com/callback"] do + client_id = "http://localhost?redirect_uri=#{URI.encode_www_form(redirect_uri)}" + + assert {:error, :invalid_client} = + ClientMetadata.fetch_for_par(%{ + "client_id" => client_id, + "redirect_uri" => redirect_uri, + "scope" => "atproto" + }) + end + end + defp metadata do %{ "client_id" => @client_id,