Skip to content

Feat/tier1 auth methods - #3

Merged
raymondproguy merged 9 commits into
mainfrom
feat/tier1-auth-methods
Sep 14, 2026
Merged

raymondproguy merged 9 commits into
mainfrom
feat/tier1-auth-methods

Conversation

@raymondproguy

Copy link
Copy Markdown
Contributor

No description provided.

Tier 1 needs the TOTP, WebAuthn, recovery-code, login-attempt and
API-key tables. Same reasoning as the existing 001/002 copies: this
repo keeps its own copy so local dev and CI stay self-contained.
Filenames continue this repo's own sequence; the header line keeps
cryden's original migration number so the source stays traceable.
Tier 1's second-factor surface, all of it a thin translation of an
engine call that already exists in cryden — no auth logic lives here.

- TOTP: enroll/confirm/disable (auth required) plus the public
  /v1/login/totp completion of a paused login.
- Passkeys: register begin/finish, list, delete-by-credential-id
  (all auth required), plus the public login begin/finish pair.
  Ceremony options travel as raw JSON so the client gets an object,
  not a JSON-encoded string.
- Magic link: request (always the same 200 either way — this does
  not create accounts, and anything else would enumerate emails) and
  complete. Needs a new notify.MagicLinkSender implementation, which
  cryden deliberately keeps separate from EmailSender.
- Recovery codes: generate (raw codes shown exactly once, and the
  response says so) and the public login completion.

A correct password is no longer always enough to finish a login, so
/v1/login (and the OAuth callback, next commit) now surfaces a
paused login as 200/second_factor_required with the pending token and
the enrolled methods instead of letting
*auth.ErrSecondFactorRequired fall through to a 500.

TOTP and WebAuthn are optional per deployment, gated on
ENCRYPTION_KEY in main.go — cryden refuses to build an engine with
either store set and no encryption key. An unconfigured method
answers 404 from mapError, the same shape oauth_provider_not_configured
already uses, rather than the server refusing to start.
The router and handler shape already took a {provider} path param, so
each of these is a case in the provider switch plus its env vars —
same authorize/token/userinfo flow Google and GitHub already use.

Also moves the code-for-token exchange onto a POST form body instead
of query parameters. RFC 6749 puts those parameters in the body, and
Microsoft and Discord require it; Google and GitHub accept the body
form too, so this replaces a per-provider branch rather than adding
one. (GitHub's form-encoded response is still avoided by the existing
Accept: application/json header.)

Apple is deliberately not in this commit — its client "secret" is a
short-lived JWT you sign yourself, the email comes back inside a
signed id_token rather than from a userinfo call, and the name is
only sent on first authorization. That is its own piece of work, not
a fourth mechanical case.
httpapi had no test files before this; these four are the first, and
they only cover what is verifiable without a database: that each new
engine error resolves to its documented (status, code) pair through
mapError (including when wrapped), and that a paused login writes the
200/second_factor_required shape with an empty array rather than null
while leaving ordinary errors for writeErr.
README gains the new endpoints, the second-factor env vars, the
paused-login response shape and the supported provider list; the env
example gains the optional OAuth and second-factor settings.

NEXT.md marks Tier 1 built-and-verified-as-far-as-this-sandbox-allows
with Apple still open; CURRENT-STATE.md gains the Tier 1 section;
PROGRESS.md gains this session's entry, including the decisions that
were not in the spec (paused login as a 200, the token exchange moving
to a form body) and two things noticed but deliberately not fixed
(unmapped password-policy errors, the .env.example/.env.exampl name
mismatch).
Neither auth.ErrPasswordPolicyViolation nor auth.ErrPasswordBreached had
a case in mapError, so a signup or password change that broke the
configured policy answered 500 internal_error and logged itself as a
server fault. Both are the caller's mistake to fix, and both now answer
400 with a code a client can branch on.

The policy error is deliberately kept as a struct case, like
ErrOAuthEmailConflict: it carries every broken rule at once as stable
codes (min_length, require_digit, ...) precisely so the client can show
them together rather than parsing prose. writeErr reads them off the
error into an optional `details` array, which is the only addition to
the error envelope and appears on no other error — the tests pin both
halves of that.
README has always told you to `cp .env.example .env`; the tracked file
was missing the final `e`, so that copy failed on a fresh clone. Renamed
to match the instruction rather than changing the instruction, since
`.env.example` is the conventional name and `.gitignore` only ignores
`.env` itself.
Tier 1's last sub-item, and deliberately not a fifth mechanical
provider case — all three of Apple's differences are handled rather
than papered over:

- The client "secret" is an ES256 JWT this repo signs itself with the
  .p8 key from Apple's developer console (apple.go's
  appleClientSecret), not a static string. APPLE_CLIENT_ID,
  APPLE_TEAM_ID, APPLE_KEY_ID and APPLE_PRIVATE_KEY are all required;
  a partially configured Apple reads as unavailable, like any other
  unconfigured provider.
- There is no userinfo endpoint. The identity comes from the `id_token`
  in the token response, so it is parsed against Apple's published
  JWKS with issuer, audience, expiry and RS256 enforced — an unverified
  decode would let anyone who can reach the callback mint an account.
  The key set is cached with a TTL and refetched on an unknown kid.
- Authorization uses response_mode=query so the existing GET callback
  route works unchanged. The name/email Apple only sends on a first
  authorization arrives in the `user` form field under form_post,
  which this repo does not need: it stores the id_token's email.

exchangeCode now takes the client secret as an argument and returns
both the access token and the id_token, which is what lets the one
Apple path reuse the existing POST plumbing instead of duplicating it.

Verified offline by unit tests (apple_test.go): the generated secret
parses as a valid ES256 JWT with the console's kid and the right
iss/aud/sub; the id_token is accepted when signed by the served key
and rejected for a wrong audience, wrong issuer, expiry, a different
signing key, an unknown kid, a missing subject, and an `alg: none`
token. Real Apple credentials and a live round trip were not available
in this sandbox — that is stated in PROGRESS.md and the docs.
README now lists apple among the providers, documents its four env
values and the two real differences (self-signed ES256 secret, identity
from a verified id_token); the env example gains the APPLE_* block.

NEXT.md drops the "except Apple" caveat and records the two Apple
decisions (response_mode=query, no nonce) plus what is still owed:
a DB-backed smoke-test run, a live Apple round trip, and the WebAuthn
ceremonies. CURRENT-STATE.md gains the Apple/what-is-tested section and
the two bug fixes, and its verification-gap paragraph now names exactly
which paths this sandbox could not exercise. PROGRESS.md gains the
continuation entry.
@raymondproguy
raymondproguy merged commit 0cdfc1e into main Sep 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant