diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
| commit | 874749df46cc291ca61f2f98092ccabfdeffbf16 (patch) | |
| tree | e4cb53b5bfa23974e7935eb2616e0e005954f782 /app/routes/api.auth.$step.ts | |
| parent | 999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff) | |
Single-user auth: any registered passkey logs in as the owner. First
registration needs a one-time token that the app writes to
data/registration-token when no passkey exists yet; once logged in,
more passkeys can be added without a token.
Logged in, /favorites manages a list of terms (e.g. band names).
Events whose title or artist contains a term (NFKC + case-insensitive)
get a ★, and the event list gains an "お気に入りのみ" filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/api.auth.$step.ts')
| -rw-r--r-- | app/routes/api.auth.$step.ts | 63 |
1 files changed, 63 insertions, 0 deletions
diff --git a/app/routes/api.auth.$step.ts b/app/routes/api.auth.$step.ts new file mode 100644 index 0000000..64940f2 --- /dev/null +++ b/app/routes/api.auth.$step.ts @@ -0,0 +1,63 @@ +/** + * Resource route: POST /api/auth/:step + * + * register-options / register-verify — パスキー登録(ログイン済み or 登録トークン必須) + * login-options / login-verify — パスキーでログイン + * logout — セッション破棄して /events へ + */ +import { redirect } from "react-router"; +import type { Route } from "./+types/api.auth.$step"; +import { + authenticationOptions, + canRegister, + isLoggedIn, + logout, + registrationOptions, + verifyAuthentication, + verifyRegistration, +} from "~/lib/auth.server"; + +export async function action({ request, params }: Route.ActionArgs) { + switch (params.step) { + case "register-options": { + const { token } = await request.json(); + if (!canRegister(request, token ?? null)) return forbidden(); + const { options, setCookie } = await registrationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "register-verify": { + const { token, response, label } = await request.json(); + const loggedIn = isLoggedIn(request); + if (!canRegister(request, token ?? null)) return forbidden(); + return withSession(() => + verifyRegistration(request, response, label ?? null, !loggedIn) + ); + } + case "login-options": { + const { options, setCookie } = await authenticationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "login-verify": { + const { response } = await request.json(); + return withSession(() => verifyAuthentication(request, response)); + } + case "logout": + return redirect("/events", { headers: { "Set-Cookie": logout(request) } }); + default: + throw new Response("Not Found", { status: 404 }); + } +} + +async function withSession(verify: () => Promise<string>): Promise<Response> { + try { + const setCookie = await verify(); + return Response.json({ ok: true }, { headers: { "Set-Cookie": setCookie } }); + } catch (e) { + console.warn("[auth]", e); + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } +} + +function forbidden(): Response { + return Response.json({ ok: false, error: "登録トークンが無効です" }, { status: 403 }); +} |
