diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be8210..7c2e9b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the Winn language are documented here. - **String escape sequences** — double-quoted strings now support `\"`, `\\`, `\n`, `\r`, `\t`, and `\0`. The lexer rule was widened from `\"[^\"]*\"` to `\"(\\.|[^\"\\])*\"`, and the formatter's symmetric `escape_string/1` re-emits the same escapes so `winn fmt` round-trips them. Unknown escapes (e.g. `\q`) pass through literally. Unblocks hand-writing Prometheus exposition format, JSON-by-hand, CSV with embedded quotes, and templated HTML attributes — previously these had to be written in Erlang. Triple-quoted strings are unchanged. (#157) ### Stdlib +- **`Auth` refresh tokens + revocation** (`Auth.refresh/1`, `Auth.logout/1`). `Auth.login` now also issues a long-lived **refresh token** alongside the short-lived access JWT, returning `%{user, access_token, refresh_token}`. The refresh token is an opaque high-entropy random string; only its SHA-256 hash is stored (in an `auth_tokens` table), so a DB leak doesn't expose live sessions. `Auth.refresh` validates and **rotates** the token (the presented one is single-use), returning a fresh `%{access_token, refresh_token}`; expired/unknown/already-rotated tokens return `:invalid_token`. `Auth.logout` deletes the token (idempotent). Adds the `auth_token` schema convention (`schema "auth_tokens"`) and `auth.token_schema` / `auth.refresh_token_ttl` (default 30d) config. See [docs/modules.md](docs/modules.md#auth). (#164) - **`Auth` module — email/password login** (`Auth.register/2`, `Auth.login/2`, `Auth.current_user/1`). A thin service layer over `Crypto` (password hashing), `JWT` (access tokens), and `Repo` (persistence) so a register/login/me flow is a few lines instead of hand-wiring all three. `register` hashes via `Crypto.hash_password` and inserts a user (returned without the password hash); `login` verifies the password and returns `%{user, access_token}` with a signed short-lived JWT; `current_user` resolves the user from the verified claims the `[:auth]` middleware attaches. Wrong password and unknown email both return `:invalid_credentials` in similar time (no user enumeration). Conventions are configurable via `Config` (`auth.secret`, `auth.user_schema`, `auth.access_token_ttl`). `Auth` maps to `winn_auth` in `winn_codegen_resolve`. See [docs/modules.md](docs/modules.md#auth). (#163) - **`Crypto.hash_password/1` and `Crypto.verify_password/2`** — secure password hashing for login flows. Uses PBKDF2-HMAC-SHA256 (600,000 iterations, OWASP-recommended) with a per-call random 16-byte salt, via `crypto:pbkdf2_hmac/5` — no new dependencies. `hash_password` returns a self-describing PHC-style string (`$pbkdf2-sha256$i=$$`) so the cost can be raised — and bcrypt/argon2 added — later without invalidating existing hashes. `verify_password` recomputes with the embedded salt/iterations and compares constant-time, returning `false` (never crashing) on a malformed or non-string hash. Replaces the unsafe `Crypto.hash(:sha256, ...)`-on-a-password pattern. (#162) - **`Timer.sleep(ms)`** — block the calling process for `ms` milliseconds. Useful in top-level scripts that need to keep the VM alive after kicking off supervisor-backed work (e.g. `pipeline` demos). diff --git a/apps/winn/src/winn_auth.erl b/apps/winn/src/winn_auth.erl index 75868a0..87340fe 100644 --- a/apps/winn/src/winn_auth.erl +++ b/apps/winn/src/winn_auth.erl @@ -1,22 +1,36 @@ -module(winn_auth). --export([middleware/3, register/2, login/2, current_user/1]). +-export([middleware/3, register/2, login/2, current_user/1, + refresh/1, logout/1]). %% This module is the `Auth` Winn module (winn_codegen_resolve maps Auth -> winn_auth) %% and also the JWT Bearer middleware used by winn_router. %% %% Service API (the `Auth.*` calls): %% Auth.register(email, password) -> {ok, user} | {error, reason} -%% Auth.login(email, password) -> {ok, %{user: user, access_token: token}} +%% Auth.login(email, password) -> {ok, %{user: u, access_token: a, refresh_token: r}} %% | {error, :invalid_credentials} +%% Auth.refresh(refresh_token) -> {ok, %{access_token: a, refresh_token: r2}} +%% | {error, :invalid_token} +%% Auth.logout(refresh_token) -> :ok %% Auth.current_user(conn) -> {ok, user} | {error, :unauthenticated} %% +%% Tokens: the access token is a short-lived JWT (stateless). The refresh token is +%% a long-lived, opaque, high-entropy random string; only its SHA-256 hash is +%% stored (in the `auth_tokens` table), so a DB leak doesn't hand over live +%% sessions. Refresh rotates (old row deleted, new issued); logout deletes the row. +%% %% Conventions (overridable via Config, see helpers at the bottom): %% - User schema module defaults to `user` (a `schema "users"` with at least %% `email`, `password_hash`; `verified` and `created_at` recommended). +%% - Token schema module defaults to `auth_token` (a `schema "auth_tokens"` with +%% `user_id`, `token_hash`, `expires_at`). %% - JWT signing secret read from Config: Config.put(:auth, :secret, "..."). -%% - Access-token TTL from Config `auth.access_token_ttl` (seconds, default 3600). +%% - Access-token TTL from Config `auth.access_token_ttl` (seconds, default 3600). +%% - Refresh-token TTL from Config `auth.refresh_token_ttl` (seconds, default 30d). -define(DEFAULT_ACCESS_TTL, 3600). +-define(DEFAULT_REFRESH_TTL, 2592000). %% 30 days +-define(REFRESH_TOKEN_BYTES, 32). %% A valid-format PHC string used to keep login timing uniform when the email is %% unknown, so an attacker can't distinguish "no such user" from "wrong password" @@ -50,9 +64,10 @@ register(Email, Password) when is_binary(Email), is_binary(Password) -> {error, Reason} end. -%% Authenticate an email/password pair. On success returns the user and a signed -%% short-lived access JWT. Wrong password and unknown email both return the same -%% `invalid_credentials` error (and take similar time) — no user enumeration. +%% Authenticate an email/password pair. On success returns the user plus a signed +%% short-lived access JWT and a rotating refresh token. Wrong password and unknown +%% email both return the same `invalid_credentials` error (and take similar time) +%% — no user enumeration. login(Email, Password) when is_binary(Email), is_binary(Password) -> Repo = repo_mod(), Schema = user_schema(), @@ -61,9 +76,8 @@ login(Email, Password) when is_binary(Email), is_binary(Password) -> Hash = maps:get(password_hash, User, <<>>), case winn_crypto:verify_password(Password, Hash) of true -> - case issue_access_token(User) of - {ok, Token} -> {ok, #{user => sanitize(User), - access_token => Token}}; + case issue_tokens(User) of + {ok, Tokens} -> {ok, Tokens#{user => sanitize(User)}}; {error, Reason} -> {error, Reason} end; false -> @@ -76,6 +90,41 @@ login(Email, Password) when is_binary(Email), is_binary(Password) -> {error, invalid_credentials} end. +%% Exchange a valid refresh token for a fresh access token and a rotated refresh +%% token. The presented token is invalidated (single use); an expired, unknown, or +%% already-rotated token returns `invalid_token`. +refresh(RawToken) when is_binary(RawToken) -> + Repo = repo_mod(), + Schema = token_schema(), + case Repo:get(Schema, token_hash, hash_token(RawToken)) of + {ok, Token} -> + _ = delete_token(Token), %% single-use: consume on any presentation + case maps:get(expires_at, Token, 0) > os:system_time(second) of + true -> + case load_user(maps:get(user_id, Token, undefined)) of + {ok, User} -> issue_tokens(User); + {error, _} -> {error, invalid_token} + end; + false -> + {error, invalid_token} + end; + {error, _} -> + {error, invalid_token} + end; +refresh(_) -> + {error, invalid_token}. + +%% Revoke a refresh token (log out). Idempotent — an unknown token is still `:ok`. +logout(RawToken) when is_binary(RawToken) -> + Repo = repo_mod(), + Schema = token_schema(), + case Repo:get(Schema, token_hash, hash_token(RawToken)) of + {ok, Token} -> _ = delete_token(Token), ok; + {error, _} -> ok + end; +logout(_) -> + ok. + %% Resolve the authenticated user from a conn. The Bearer middleware attaches the %% verified JWT claims (binary keys) under `claims`; we load the user by `user_id`. current_user(Conn) when is_map(Conn) -> @@ -126,6 +175,19 @@ middleware(Conn, Next, Config) -> %% ── Internal ──────────────────────────────────────────────────────────────── +%% Issue an access + refresh token pair for a user. +issue_tokens(User) -> + case issue_access_token(User) of + {ok, Access} -> + case issue_refresh_token(user_id(User)) of + {ok, Refresh} -> {ok, #{access_token => Access, + refresh_token => Refresh}}; + {error, Reason} -> {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + %% Sign an access token for a user. Subject is the user id; email is included for %% convenience. Fails if no signing secret is configured. issue_access_token(User) -> @@ -134,12 +196,47 @@ issue_access_token(User) -> {error, missing_secret}; Secret -> Exp = os:system_time(second) + access_ttl(), - Claims = #{<<"user_id">> => maps:get(id, User, maps:get(email, User, null)), + Claims = #{<<"user_id">> => user_id(User), <<"email">> => maps:get(email, User, null), <<"exp">> => Exp}, {ok, winn_jwt:sign(Claims, Secret)} end. +%% Create and store a refresh token; returns the raw token (shown to the client +%% once). Only the hash is persisted. +issue_refresh_token(UserId) -> + Repo = repo_mod(), + Raw = gen_refresh_token(), + Attrs = #{user_id => UserId, + token_hash => hash_token(Raw), + expires_at => os:system_time(second) + refresh_ttl(), + created_at => os:system_time(second)}, + case Repo:insert(token_schema(), Attrs) of + {ok, _} -> {ok, Raw}; + {error, Reason} -> {error, Reason} + end. + +%% High-entropy URL-safe refresh token. +gen_refresh_token() -> + binary:encode_hex(crypto:strong_rand_bytes(?REFRESH_TOKEN_BYTES)). + +%% Refresh tokens are high-entropy random values, so a fast SHA-256 (not PBKDF2) +%% is the right at-rest hash; it also makes lookup-by-hash a single indexed query. +hash_token(Raw) -> + winn_crypto:hash(sha256, Raw). + +delete_token(Token) -> + Repo = repo_mod(), + Repo:delete(Token#{'__schema__' => token_schema()}). + +load_user(undefined) -> {error, not_found}; +load_user(UserId) -> + Repo = repo_mod(), + Repo:get(user_schema(), UserId). + +%% Subject id for tokens/claims — the row id, falling back to email. +user_id(User) -> maps:get(id, User, maps:get(email, User, null)). + %% Strip the password hash before returning a user to callers. sanitize(User) when is_map(User) -> maps:remove(password_hash, User); sanitize(User) -> User. @@ -158,6 +255,20 @@ user_schema() -> Schema -> Schema end. +%% Refresh-token schema module — defaults to `auth_token` (a `schema "auth_tokens"`). +token_schema() -> + case winn_config:get(auth, token_schema) of + nil -> auth_token; + Schema -> Schema + end. + +refresh_ttl() -> + case winn_config:get(auth, refresh_token_ttl) of + nil -> ?DEFAULT_REFRESH_TTL; + T when is_integer(T) -> T; + _ -> ?DEFAULT_REFRESH_TTL + end. + %% JWT signing secret (binary) from Config, or nil if unset. secret() -> winn_config:get(auth, secret). diff --git a/apps/winn/test/winn_auth_fake_repo.erl b/apps/winn/test/winn_auth_fake_repo.erl index af21394..8f204f8 100644 --- a/apps/winn/test/winn_auth_fake_repo.erl +++ b/apps/winn/test/winn_auth_fake_repo.erl @@ -6,10 +6,12 @@ %% insert/2 -> {ok, RecordMap} (atom keys, with an auto-assigned `id`) %% get/2 (id) -> {ok, RecordMap} | {error, not_found} %% get/3 (field)-> {ok, RecordMap} | {error, not_found} +%% delete/1 -> ok (by `id`, like winn_repo:delete/1) %% -%% Inject it via: winn_config:put(auth, repo_module, winn_auth_fake_repo). +%% Holds both users and auth tokens (fields don't collide), so it backs the whole +%% Auth flow. Inject it via: winn_config:put(auth, repo_module, winn_auth_fake_repo). -module(winn_auth_fake_repo). --export([reset/0, insert/2, get/2, get/3]). +-export([reset/0, insert/2, get/2, get/3, delete/1]). -define(TAB, winn_auth_fake_repo_tab). @@ -21,11 +23,19 @@ reset() -> insert(_Schema, Attrs) when is_map(Attrs) -> ensure(), - Id = ets:info(?TAB, size) + 1, + %% Monotonic id so deletes never free an id a later insert could reuse. + Id = erlang:unique_integer([positive, monotonic]), Rec = Attrs#{id => Id}, ets:insert(?TAB, {Id, Rec}), {ok, Rec}. +delete(#{id := Id}) -> + ensure(), + ets:delete(?TAB, Id), + ok; +delete(_) -> + {error, not_a_schema_struct}. + get(_Schema, Id) -> ensure(), case ets:lookup(?TAB, Id) of diff --git a/apps/winn/test/winn_auth_tests.erl b/apps/winn/test/winn_auth_tests.erl index acfbeeb..2760c3b 100644 --- a/apps/winn/test/winn_auth_tests.erl +++ b/apps/winn/test/winn_auth_tests.erl @@ -73,7 +73,9 @@ auth_config_with_exclude_compiles_test() -> setup_auth() -> winn_config:put(auth, repo_module, winn_auth_fake_repo), winn_config:put(auth, user_schema, user), + winn_config:put(auth, token_schema, auth_token), winn_config:put(auth, secret, <<"test_secret">>), + winn_config:put(auth, refresh_token_ttl, nil), %% default; reset per test winn_auth_fake_repo:reset(). register_returns_sanitized_user_test() -> @@ -136,6 +138,52 @@ login_missing_secret_test() -> ?assertEqual({error, missing_secret}, winn_auth:login(<<"eve@example.com">>, <<"pw">>)). +%% ── Refresh tokens / revocation ────────────────────────────────────────────── + +login_returns_refresh_token_test() -> + setup_auth(), + {ok, _} = winn_auth:register(<<"ivan@example.com">>, <<"pw">>), + {ok, L} = winn_auth:login(<<"ivan@example.com">>, <<"pw">>), + ?assert(is_binary(maps:get(access_token, L))), + ?assert(is_binary(maps:get(refresh_token, L))). + +refresh_rotates_and_invalidates_old_test() -> + setup_auth(), + {ok, _} = winn_auth:register(<<"frank@example.com">>, <<"pw">>), + {ok, L} = winn_auth:login(<<"frank@example.com">>, <<"pw">>), + RT1 = maps:get(refresh_token, L), + {ok, R} = winn_auth:refresh(RT1), + RT2 = maps:get(refresh_token, R), + ?assert(is_binary(maps:get(access_token, R))), + ?assertNotEqual(RT1, RT2), + %% Old token is single-use — rotated away. + ?assertEqual({error, invalid_token}, winn_auth:refresh(RT1)), + %% New token works. + ?assertMatch({ok, _}, winn_auth:refresh(RT2)). + +logout_revokes_refresh_token_test() -> + setup_auth(), + {ok, _} = winn_auth:register(<<"grace@example.com">>, <<"pw">>), + {ok, L} = winn_auth:login(<<"grace@example.com">>, <<"pw">>), + RT = maps:get(refresh_token, L), + ?assertEqual(ok, winn_auth:logout(RT)), + ?assertEqual({error, invalid_token}, winn_auth:refresh(RT)), + %% Idempotent. + ?assertEqual(ok, winn_auth:logout(RT)). + +refresh_expired_token_test() -> + setup_auth(), + winn_config:put(auth, refresh_token_ttl, -1), %% issued already-expired + {ok, _} = winn_auth:register(<<"heidi@example.com">>, <<"pw">>), + {ok, L} = winn_auth:login(<<"heidi@example.com">>, <<"pw">>), + ?assertEqual({error, invalid_token}, + winn_auth:refresh(maps:get(refresh_token, L))). + +refresh_unknown_token_test() -> + setup_auth(), + ?assertEqual({error, invalid_token}, winn_auth:refresh(<<"not-a-real-token">>)), + ?assertEqual({error, invalid_token}, winn_auth:refresh(<<>>)). + %% End-to-end through the compiler: Winn source using `Auth.register` / `Auth.login` %% must resolve (winn_codegen_resolve maps Auth -> winn_auth) and run. e2e_auth_register_login_test() -> @@ -154,6 +202,20 @@ e2e_auth_register_login_test() -> ?assert(is_binary(Token)), ?assertNotEqual(<<"failed">>, Token). +%% `Auth.refresh` must resolve through codegen (Auth -> winn_auth) and run. +e2e_auth_refresh_resolves_test() -> + setup_auth(), + Source = "module RefreshResolve\n" + " def run()\n" + " match Auth.refresh(\"garbage\")\n" + " ok _ => \"ok\"\n" + " err _ => \"err\"\n" + " end\n" + " end\n" + "end\n", + Mod = compile_and_load(Source), + ?assertEqual(<<"err">>, Mod:run()). + compile_and_load(Source) -> {ok, RawTokens, _} = winn_lexer:string(Source), Tokens = winn_newline_filter:filter(RawTokens), diff --git a/docs/modules.md b/docs/modules.md index fc57255..f7ef3f6 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -371,10 +371,11 @@ A small service layer over `Crypto` (password hashing), `JWT` (tokens), and `Rep dance so your handlers stay a few lines. Tokens are Bearer JWTs; the `[:auth]` middleware (see [Middleware](#middleware)) verifies them and attaches the claims. -### User schema convention +### Schema conventions -`Auth` expects a schema named `user` (a `schema "users"` block) with at least an -`email` and a `password_hash`. `verified` and `created_at` are recommended: +`Auth` expects a `user` schema (a `schema "users"` block) with at least an `email` +and a `password_hash`, plus an `auth_token` schema (`schema "auth_tokens"`) that +backs refresh tokens: ```winn module User @@ -387,14 +388,45 @@ module User field :created_at, :integer end end + +module AuthToken + use Winn.Schema + + schema "auth_tokens" do + field :user_id, :integer + field :token_hash, :string + field :expires_at, :integer + field :created_at, :integer + end +end ``` -Set the JWT signing secret (and, optionally, the access-token TTL in seconds) in -config once at startup: +Migration for the token table: + +```winn +module Migrations.CreateAuthTokens + def up() + Repo.execute("CREATE TABLE auth_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + )") + end + + def down() + Repo.execute("DROP TABLE auth_tokens") + end +end +``` + +Set the JWT signing secret and (optionally) the token TTLs in config at startup: ```winn Config.put(:auth, :secret, System.get_env("JWT_SECRET")) -Config.put(:auth, :access_token_ttl, 3600) # optional, default 3600 +Config.put(:auth, :access_token_ttl, 3600) # optional, default 3600 (1h) +Config.put(:auth, :refresh_token_ttl, 2592000) # optional, default 30 days ``` ### `Auth.register(email, password)` @@ -412,17 +444,45 @@ end ### `Auth.login(email, password)` -Verifies the password and, on success, returns the user plus a signed access -token. A wrong password and an unknown email both return `:invalid_credentials` -(and take similar time) so attackers can't probe which emails exist. +Verifies the password and, on success, returns the user plus a short-lived +**access token** (JWT) and a long-lived **refresh token**. A wrong password and an +unknown email both return `:invalid_credentials` (and take similar time) so +attackers can't probe which emails exist. ```winn match Auth.login("alice@example.com", "hunter2") - ok result => Server.json(conn, result) # %{user: ..., access_token: "..."} + ok result => Server.json(conn, result) + # result => %{user: ..., access_token: "...", refresh_token: "..."} err :invalid_credentials => Server.json(conn, %{error: "invalid login"}, 401) end ``` +The access token is stateless and short-lived; the refresh token is opaque, +stored server-side (only its hash), and revocable. + +### `Auth.refresh(refresh_token)` + +Exchanges a valid refresh token for a new access token **and a rotated refresh +token** — the presented token is single-use and stops working after this call. An +expired, unknown, or already-rotated token returns `:invalid_token`. + +```winn +match Auth.refresh(params.refresh_token) + ok tokens => Server.json(conn, tokens) + # tokens => %{access_token: "...", refresh_token: "..."} + err :invalid_token => Server.json(conn, %{error: "invalid refresh token"}, 401) +end +``` + +### `Auth.logout(refresh_token)` + +Revokes a refresh token (deletes it server-side). Idempotent — always returns `:ok`. + +```winn +Auth.logout(params.refresh_token) +Server.json(conn, %{ok: true}) +``` + ### `Auth.current_user(conn)` Resolves the authenticated user from the conn. The `[:auth]` middleware verifies @@ -446,6 +506,8 @@ module Api.Router [ {:post, "/auth/register", :register}, {:post, "/auth/login", :login}, + {:post, "/auth/refresh", :refresh}, + {:post, "/auth/logout", :logout}, {:get, "/api/me", :me} ] end @@ -455,7 +517,10 @@ module Api.Router end def auth_config() - %{secret: Config.get(:auth, :secret), exclude: ["/auth/login", "/auth/register"]} + %{ + secret: Config.get(:auth, :secret), + exclude: ["/auth/login", "/auth/register", "/auth/refresh"] + } end def register(conn) @@ -474,6 +539,20 @@ module Api.Router end end + def refresh(conn) + params = Server.body_params(conn) + match Auth.refresh(params.refresh_token) + ok tokens => Server.json(conn, tokens) + err _ => Server.json(conn, %{error: "invalid refresh token"}, 401) + end + end + + def logout(conn) + params = Server.body_params(conn) + Auth.logout(params.refresh_token) + Server.json(conn, %{ok: true}) + end + def me(conn) match Auth.current_user(conn) ok user => Server.json(conn, user) @@ -483,20 +562,39 @@ module Api.Router end ``` -A JS frontend logs in, then sends the token on protected requests: +A JS frontend logs in, calls protected endpoints with the access token, and +silently refreshes when it expires: ```js -const { access_token } = await (await fetch("/auth/login", { +let { access_token, refresh_token } = await (await fetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), })).json(); -await fetch("/api/me", { headers: { Authorization: `Bearer ${access_token}` } }); +let res = await fetch("/api/me", { + headers: { Authorization: `Bearer ${access_token}` }, +}); + +if (res.status === 401) { + // access token expired — rotate and retry + ({ access_token, refresh_token } = await (await fetch("/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token }), + })).json()); +} + +// on sign-out: +await fetch("/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token }), +}); ``` -> Refresh tokens, logout/revocation, and cookie sessions build on this in later -> releases. This is the core access-token flow. +> Cookie sessions and account recovery (email verification, password reset) build +> on this in later releases. --- diff --git a/docs/stdlib.md b/docs/stdlib.md index e126109..4b3d5b4 100644 --- a/docs/stdlib.md +++ b/docs/stdlib.md @@ -351,12 +351,27 @@ Auth.register("alice@example.com", "hunter2") ``` ### `Auth.login(email, password)` -Verify credentials and return `%{user: ..., access_token: token}`, or -`{:error, :invalid_credentials}` (same error for wrong password and unknown email). +Verify credentials and return `%{user: ..., access_token: token, refresh_token: token}`, +or `{:error, :invalid_credentials}` (same error for wrong password and unknown email). +Needs an `auth_token` schema for the refresh token — see the [Auth guide](modules.md#auth). ```winn Auth.login("alice@example.com", "hunter2") ``` +### `Auth.refresh(refresh_token)` +Rotate a refresh token: returns a fresh `%{access_token, refresh_token}` and +invalidates the presented (single-use) token. Returns `{:error, :invalid_token}` if +it's expired, unknown, or already rotated. +```winn +Auth.refresh(token) +``` + +### `Auth.logout(refresh_token)` +Revoke a refresh token. Idempotent; returns `:ok`. +```winn +Auth.logout(token) +``` + ### `Auth.current_user(conn)` Resolve the authenticated user from the conn's verified JWT claims (attached by the `[:auth]` middleware). Returns the user or `{:error, :unauthenticated}`.