NextMyDay/internal/core/auth/flow/render.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

129 lines
5.2 KiB
Go

package flow
import (
"html"
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// renderHTML writes a server-rendered page inside the core shell (SPEC-009 §4).
// The door renders no dynamic HTML itself (SPEC-003 §4); these are the flow's
// hand-off targets.
//
// F-2: the enrollment screen carries the TOTP ceremony and the login screen a
// session — neither may be cached, so every flow response is `no-store`.
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
w.Header().Set("Cache-Control", "no-store")
return web.Render(w, status, page)
}
// enrollPage is the enrollment screen (SPEC-006 §4). The passkey ceremony is
// driven by the same-origin WebAuthn client (SPEC-009 §5) from the data-*
// contract on <main>; the TOTP forms work without JavaScript. Every form
// carries the signed pre-session CSRF token (A15), which the client also reads
// from the shell's <meta name="csrf-token">.
func enrollPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Enrollment",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="enroll"` +
` data-begin=` + action("enroll.begin") +
` data-finish=` + action("enroll.finish")),
Content: web.Markup(`<h1>Enrollment</h1>
<p>Two factors are required: a passkey <strong>and</strong> an authenticator app.</p>
<section aria-label="passkey">
<h2>1. Passkey</h2>
<p id="passkey-status" data-webauthn-status>Register a passkey for this device.</p>
<button type="button" data-webauthn-start>Register a passkey</button>
<noscript><p>Registering a passkey needs JavaScript in the browser. You can still set up your authenticator app below, and add a passkey later at sign-in.</p></noscript>
</section>
<section aria-label="totp">
<h2>2. Authenticator app</h2>
<form method="post" action=` + action("totp.begin") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Set up authenticator</button></form>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Verify code</button></form>
</section>`),
}
}
// passkeyPage is the post-login passkey nudge (SPEC-006 §8, D-041). The
// WebAuthn registration is begun server-side and the options are embedded for
// the browser; the single `passkey.add` action completes it. The two skip forms
// work without JavaScript. Every form carries the SESSION-bound CSRF token (a
// session exists here — A15 does not apply).
func passkeyPage(guid, token, options string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Add a passkey",
GUID: guid,
CSRF: token,
Flash: "Skip the one-time code next time by adding a passkey for this device.",
MainAttrs: web.Attrs(`data-webauthn="passkey"` +
` data-finish=` + action("passkey.add") +
` data-options="` + html.EscapeString(options) + `"`),
Content: web.Markup(`<h1>Add a passkey</h1>
<section aria-label="passkey">
<p id="passkey-status" data-webauthn-status>Use this device's screen lock or security key.</p>
<button type="button" data-webauthn-start>Add a passkey</button>
<noscript><p>Adding a passkey needs JavaScript in the browser.</p></noscript>
</section>
<section aria-label="skip">
<h2>Not now?</h2>
<form method="post" action=` + action("passkey.later") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Not now</button></form>
<form method="post" action=` + action("passkey.never") + `>
<input type="hidden" name="csrf" value="` + c + `">
<button type="submit" class="secondary">Don&#39;t ask again</button></form>
</section>`),
}
}
// loginPage is the login screen (SPEC-006 §5): Passkey primary, TOTP fallback.
func loginPage(guid, token string) web.Page {
g := html.EscapeString(guid)
c := html.EscapeString(token)
action := func(name string) string {
return `"/` + g + `?do=auth&amp;action=` + name + `"`
}
return web.Page{
Title: "Sign in",
GUID: guid,
Guest: true,
CSRF: token,
MainAttrs: web.Attrs(`data-webauthn="login"` +
` data-begin=` + action("login.begin") +
` data-finish=` + action("login.finish")),
Content: web.Markup(`<h1>Sign in</h1>
<p>Use a passkey for this device, or your authenticator code.</p>
<section aria-label="passkey">
<h2>Passkey</h2>
<p id="passkey-status" data-webauthn-status>Sign in with a passkey.</p>
<button type="button" data-webauthn-start>Use a passkey</button>
<noscript><p>Passkey sign-in needs JavaScript in the browser. Use your authenticator code below.</p></noscript>
</section>
<section aria-label="totp">
<h2>Authenticator code</h2>
<form method="post" action=` + action("totp.verify") + `>
<input type="hidden" name="csrf" value="` + c + `">
<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>
<button type="submit">Use an authenticator code</button></form>
</section>`),
}
}