Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<iter>$<salt_b64>$<hash_b64>`) 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).
Expand Down
131 changes: 121 additions & 10 deletions apps/winn/src/winn_auth.erl
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(),
Expand All @@ -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 ->
Expand All @@ -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) ->
Expand Down Expand Up @@ -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) ->
Expand All @@ -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.
Expand All @@ -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).
Expand Down
16 changes: 13 additions & 3 deletions apps/winn/test/winn_auth_fake_repo.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions apps/winn/test/winn_auth_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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() ->
Expand Down Expand Up @@ -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() ->
Expand All @@ -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),
Expand Down
Loading
Loading