diff --git a/CLAUDE.md b/CLAUDE.md index 4f37aaa..097b039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ The web frontend is a Bangladesh-first budget and receipt tracking prototype. Th Connected mode uses the existing ScribbleScan API only through `/v1`. Response types are generated from `openapi.json` into `src/api/generated`; never edit generated files by hand. The Next.js rewrites in `next.config.ts` target `http://127.0.0.1:8000` by default and can be overridden with `SCRIBBLESCAN_API`. +Sign-in (`app/connect/page.tsx`) is passwordless: **Continue with email** (a code, or the link in the email), **Google** and **Apple**, through `/v1/auth/*`. The page makes the PKCE verifier (`oidc.begin()`, kept in localStorage so the emailed link works in a new tab), the API runs the sign-in with Zitadel, and the browser lands on `/?code=...`, which the app shell hands to `/connect` for `oidc.finish()`. `GET /v1/config`'s `login_methods` says which choices work; empty with `auth: "oidc"` means Zitadel's hosted pages, and dev mode signs in from the email step without a code. + The phone pairing secret belongs only in the scan URL fragment. QR SVGs must come from `POST /v1/qr`, never from a URL containing the secret. ## Commands diff --git a/app/connect/page.tsx b/app/connect/page.tsx index c45d70e..d404293 100644 --- a/app/connect/page.tsx +++ b/app/connect/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { type FormEvent, useCallback, useEffect, useState } from "react"; +import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { api, DEMO_ACCOUNT_EMAIL, @@ -10,100 +10,269 @@ import { session, startDemoSession, } from "@/src/api/client"; -import type { DevToken } from "@/src/api/generated"; +import type { DevToken, EmailCodeSent, Redirect } from "@/src/api/generated"; import type { ClientConfig } from "@/src/api/types"; import * as oidc from "@/src/oidc"; +import { store } from "@/src/store"; + +// Passwordless sign-in: a code by email, Google, or Apple. The API runs the sign-in with +// Zitadel and answers with the usual ?code= redirect, which oidc.finish() completes here. +// Without a login client on the server it falls back to Zitadel's hosted pages; in dev +// mode the email step signs in directly. + +type Provider = "google" | "apple"; +type Step = + | { name: "choose" } + | { name: "email" } + | { name: "code"; flow: string; email: string; resendAt: number } + | { name: "busy"; note: string }; + +const RETURN_TO = "ss.returnTo"; +const PROVIDER_NAMES: Record = { google: "Google", apple: "Apple" }; export default function ConnectPage() { const [config, setConfig] = useState(null); + const [step, setStep] = useState({ name: "busy", note: "Checking the connection…" }); const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); const [error, setError] = useState(""); - const [ready, setReady] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const heading = useRef(null); const destination = useCallback(() => { - const requested = new URLSearchParams(window.location.search).get("returnTo"); + const requested = new URLSearchParams(window.location.search).get("returnTo") ?? store.get(RETURN_TO); + store.set(RETURN_TO, null); return requested?.startsWith("/") && !requested.startsWith("//") ? requested : "/"; }, []); useEffect(() => { + const go = (url: string) => { + setStep({ name: "busy", note: "Signing you in…" }); + window.location.assign(url); + }; + const failed = (cause: unknown) => { + setError(cause instanceof Error ? cause.message : "Couldn't sign in. Try again."); + setStep({ name: "choose" }); + }; void api("/v1/config") .then(async (clientConfig) => { session.config = clientConfig; setConfig(clientConfig); + const q = new URLSearchParams(window.location.search); + const flow = q.get("flow"); + if (flow) { + // The link in a sign-in email, or the way back from Google or Apple. + history.replaceState(null, "", window.location.pathname); + if (q.get("code")) { + setStep({ name: "busy", note: "Checking your code…" }); + const next = await api("/v1/auth/email/verify", { + method: "POST", + body: { flow, code: q.get("code") }, + }); + return go(next.url); + } + if (q.get("id") && q.get("token")) { + setStep({ name: "busy", note: "Signing you in…" }); + const next = await api("/v1/auth/provider/finish", { + method: "POST", + body: { flow, intent_id: q.get("id"), intent_token: q.get("token") }, + }); + return go(next.url); + } + throw new Error("Sign-in was cancelled. Try again."); + } const oidcSettings = oidcConfig(); if (oidcSettings) { const tokens = await oidc.finish(oidcSettings); if (tokens) saveTokens(tokens); } - if (session.token || isDemoSession()) window.location.assign(destination()); - setReady(true); + if (session.token || isDemoSession()) return window.location.assign(destination()); + setStep({ name: "choose" }); }) - .catch(() => { + .catch((cause) => { + if (session.config) return failed(cause); setError(`Couldn't reach the API. You can still preview the app with ${DEMO_ACCOUNT_EMAIL}.`); - setReady(true); + setStep({ name: "choose" }); }); }, [destination]); - const signInDev = async (event: FormEvent) => { - event.preventDefault(); + // Move focus to each step's heading, so keyboard and screen reader users follow along. + useEffect(() => { + if (step.name !== "busy") heading.current?.focus(); + }, [step.name]); + + useEffect(() => { + if (step.name !== "code") return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [step.name]); + + const methods = config?.login_methods ?? []; + const passwordless = config?.auth === "oidc" && methods.length > 0; + const hosted = config?.auth === "oidc" && methods.length === 0; + const available = (provider: Provider) => passwordless && methods.includes(provider); + // Dev mode, the hosted fallback and an offline API each handle the email step their own way. + const emailAvailable = !passwordless || methods.includes("email"); + + const leaving = () => store.set(RETURN_TO, destination()); + + const goHosted = () => { + const settings = oidcConfig(); + if (!settings) return; + leaving(); + setStep({ name: "busy", note: "Taking you to sign in…" }); + void oidc.login(settings).catch(() => { + setError("Couldn't reach the sign-in page. Try again."); + setStep({ name: "choose" }); + }); + }; + + const chooseProvider = async (provider: Provider) => { + setError(""); + if (hosted) return goHosted(); + if (!available(provider)) { + const instead = emailAvailable ? " Continue with email." : ""; + setError(`${PROVIDER_NAMES[provider]} sign-in isn't set up on this server yet.${instead}`); + return; + } + try { + setStep({ name: "busy", note: `Opening ${PROVIDER_NAMES[provider]}…` }); + leaving(); + const next = await api("/v1/auth/provider", { + method: "POST", + body: { provider, ...(await oidc.begin()) }, + }); + window.location.assign(next.url); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn't start the sign-in. Try again."); + setStep({ name: "choose" }); + } + }; + + const chooseEmail = () => { + setError(""); + if (hosted) return goHosted(); + if (!emailAvailable) { + setError("Email sign-in isn't set up on this server yet."); + return; + } + setStep({ name: "email" }); + }; + + const sendCode = async (address: string) => { setError(""); - if (startDemoSession(email)) { + if (startDemoSession(address)) { window.location.assign(destination()); return; } - if (config?.auth !== "dev") { + if (config === null) { setError(`Live sign-in needs the API. Use ${DEMO_ACCOUNT_EMAIL} to preview the app.`); return; } try { - const response = await fetch("/v1/dev/token", { + if (config.auth === "dev") { + const token = await api("/v1/dev/token", { + method: "POST", + body: { email: address.trim() }, + }); + saveTokens({ access_token: token.access_token, expires_in: 12 * 3600 }); + window.location.assign(destination()); + return; + } + leaving(); + const sent = await api("/v1/auth/email", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: email.trim() }), + body: { email: address, ...(await oidc.begin()) }, + }); + setCode(""); + setStep({ + name: "code", + flow: sent.flow, + email: sent.email, + resendAt: Date.now() + sent.resend_after * 1000, }); - if (!response.ok) throw new Error("Couldn't sign in. Check the email address."); - const token = (await response.json()) as DevToken; - saveTokens({ access_token: token.access_token, expires_in: 12 * 3600 }); - window.location.assign(destination()); } catch (cause) { - setError(cause instanceof Error ? cause.message : "Couldn't sign in. Try again."); + setError(cause instanceof Error ? cause.message : "Couldn't send a code. Try again."); } }; - const login = (prompt?: string) => { - const settings = oidcConfig(); - if (settings) - void oidc - .login(settings, { prompt }) - .catch(() => setError("Couldn't reach the sign-in page. Try again.")); + const submitEmail = (event: FormEvent) => { + event.preventDefault(); + void sendCode(email); + }; + + const submitCode = async (event: FormEvent) => { + event.preventDefault(); + if (step.name !== "code") return; + setError(""); + const current = step; + try { + setStep({ name: "busy", note: "Checking your code…" }); + const next = await api("/v1/auth/email/verify", { + method: "POST", + body: { flow: current.flow, code: code.trim() }, + }); + window.location.assign(next.url); + } catch (cause) { + const message = cause instanceof Error ? cause.message : "Couldn't check the code. Try again."; + setError(message.charAt(0).toUpperCase() + message.slice(1)); + setStep(/start again/.test(message) ? { name: "email" } : current); + } }; + const resendIn = step.name === "code" ? Math.max(0, Math.ceil((step.resendAt - now) / 1000)) : 0; + return ( -
-
); } + +const FIELD = + "w-full rounded-full border border-line bg-transparent px-5 py-3.5 text-base text-ink outline-none transition placeholder:text-quiet focus:border-ledger"; +const PRIMARY = + "w-full rounded-full bg-[var(--brand-tile)] px-5 py-3.5 text-base font-semibold text-[var(--brand-ink)] transition hover:opacity-90"; +const LINK = "w-full rounded-full px-5 py-2 text-sm font-medium text-quiet transition hover:text-ink"; + +function Title({ + title, + children, + headingRef, +}: { + title: string; + children: ReactNode; + headingRef: React.RefObject; +}) { + return ( +
+

+ {title} +

+

{children}

+
+ ); +} + +function ChoiceButton({ + onClick, + icon, + unavailable = false, + children, +}: { + onClick: () => void; + icon: ReactNode; + unavailable?: boolean; + children: ReactNode; +}) { + return ( + + ); +} + +function MailMark() { + return ( + + ); +} + +function GoogleMark() { + return ( + + ); +} + +function AppleMark() { + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css index e3b0bac..5389413 100644 --- a/app/globals.css +++ b/app/globals.css @@ -106,6 +106,11 @@ select { outline-offset: 2px; } +/* Headings get focus from code (so screen readers follow a step change), not from Tab. */ +[tabindex="-1"]:focus-visible { + outline: none; +} + ::selection { background: #285b4d; color: #fff; diff --git a/biome.json b/biome.json index bfdcade..b40c846 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { - "includes": ["**", "!src/api/generated", "!dist", "!openapi.json"] + "includes": ["**", "!.next", "!src/api/generated", "!dist", "!openapi.json"] }, "formatter": { "indentStyle": "space", diff --git a/openapi.json b/openapi.json index 1c1e855..0b7b125 100644 --- a/openapi.json +++ b/openapi.json @@ -1298,6 +1298,174 @@ } } } + }, + "/v1/auth/email": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Email Code", + "description": "Email a sign-in code. New addresses get an account once the code is entered.", + "operationId": "email_code_v1_auth_email_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeSent" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/email/verify": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Email Code Check", + "description": "Check the code; the browser then goes to the returned URL to collect its tokens.", + "operationId": "email_code_check_v1_auth_email_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailCodeCheck" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/provider": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Provider Start", + "description": "Where to send the browser to sign in with Google or Apple.", + "operationId": "provider_start_v1_auth_provider_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/v1/auth/provider/finish": { + "post": { + "tags": [ + "sign-in" + ], + "summary": "Provider Finish", + "description": "Back from Google or Apple: complete the sign-in.", + "operationId": "provider_finish_v1_auth_provider_finish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderReturn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Redirect" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -1501,6 +1669,19 @@ } ], "title": "Scopes" + }, + "login_methods": { + "items": { + "type": "string", + "enum": [ + "email", + "google", + "apple" + ] + }, + "type": "array", + "title": "Login Methods", + "default": [] } }, "type": "object", @@ -1837,6 +2018,87 @@ "type": "object", "title": "DocumentPatch" }, + "EmailCodeCheck": { + "properties": { + "flow": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Flow" + }, + "code": { + "type": "string", + "maxLength": 16, + "minLength": 4, + "title": "Code" + } + }, + "type": "object", + "required": [ + "flow", + "code" + ], + "title": "EmailCodeCheck" + }, + "EmailCodeIn": { + "properties": { + "code_challenge": { + "type": "string", + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Code Challenge" + }, + "state": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "State" + }, + "email": { + "type": "string", + "maxLength": 320, + "minLength": 3, + "title": "Email" + } + }, + "type": "object", + "required": [ + "code_challenge", + "state", + "email" + ], + "title": "EmailCodeIn" + }, + "EmailCodeSent": { + "properties": { + "flow": { + "type": "string", + "title": "Flow" + }, + "email": { + "type": "string", + "title": "Email" + }, + "expires_in": { + "type": "integer", + "title": "Expires In" + }, + "resend_after": { + "type": "integer", + "title": "Resend After" + } + }, + "type": "object", + "required": [ + "flow", + "email", + "expires_in", + "resend_after" + ], + "title": "EmailCodeSent" + }, "Expenses": { "properties": { "month": { @@ -2156,6 +2418,69 @@ "title": "PairOffer", "description": "Show qr_url as a QR code. shower_key opens the offer's event stream (/v1/pair/offers/{id}/events)." }, + "ProviderIn": { + "properties": { + "code_challenge": { + "type": "string", + "maxLength": 128, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "Code Challenge" + }, + "state": { + "type": "string", + "maxLength": 128, + "minLength": 8, + "pattern": "^[A-Za-z0-9_-]+$", + "title": "State" + }, + "provider": { + "type": "string", + "enum": [ + "google", + "apple" + ], + "title": "Provider" + } + }, + "type": "object", + "required": [ + "code_challenge", + "state", + "provider" + ], + "title": "ProviderIn" + }, + "ProviderReturn": { + "properties": { + "flow": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Flow" + }, + "intent_id": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "title": "Intent Id" + }, + "intent_token": { + "type": "string", + "maxLength": 4096, + "minLength": 1, + "title": "Intent Token" + } + }, + "type": "object", + "required": [ + "flow", + "intent_id", + "intent_token" + ], + "title": "ProviderReturn", + "description": "What Zitadel appended to the return URL after Google or Apple." + }, "QrIn": { "properties": { "data": { @@ -2171,6 +2496,20 @@ ], "title": "QrIn" }, + "Redirect": { + "properties": { + "url": { + "type": "string", + "title": "Url" + } + }, + "type": "object", + "required": [ + "url" + ], + "title": "Redirect", + "description": "Send the browser here next." + }, "SpaceIn": { "properties": { "name": { diff --git a/src/api/generated/index.ts b/src/api/generated/index.ts index 14a541f..fc6e6f8 100644 --- a/src/api/generated/index.ts +++ b/src/api/generated/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { ApproveV1PairOffersOfferIdApprovePostData, ApproveV1PairOffersOfferIdApprovePostError, ApproveV1PairOffersOfferIdApprovePostErrors, ApproveV1PairOffersOfferIdApprovePostResponse, ApproveV1PairOffersOfferIdApprovePostResponses, BodyUploadV1SpacesSpaceIdDocumentsPost, BudgetsIn, CategoriesV1CategoriesGetData, CategoriesV1CategoriesGetResponse, CategoriesV1CategoriesGetResponses, CategorySpend, Claimed, ClaimIn, ClaimV1PairClaimPostData, ClaimV1PairClaimPostError, ClaimV1PairClaimPostErrors, ClaimV1PairClaimPostResponse, ClaimV1PairClaimPostResponses, ClientConfig, ClientConfigV1ConfigGetData, ClientConfigV1ConfigGetResponse, ClientConfigV1ConfigGetResponses, ClientOptions, Collected, CollectV1PairOffersOfferIdCollectPostData, CollectV1PairOffersOfferIdCollectPostError, CollectV1PairOffersOfferIdCollectPostErrors, CollectV1PairOffersOfferIdCollectPostResponse, CollectV1PairOffersOfferIdCollectPostResponses, CreateOfferIn, CreateOfferV1PairOffersPostData, CreateOfferV1PairOffersPostError, CreateOfferV1PairOffersPostErrors, CreateOfferV1PairOffersPostResponse, CreateOfferV1PairOffersPostResponses, CreateSpaceV1SpacesPostData, CreateSpaceV1SpacesPostError, CreateSpaceV1SpacesPostErrors, CreateSpaceV1SpacesPostResponse, CreateSpaceV1SpacesPostResponses, DeleteDocumentV1DocumentsDocIdDeleteData, DeleteDocumentV1DocumentsDocIdDeleteError, DeleteDocumentV1DocumentsDocIdDeleteErrors, DeleteDocumentV1DocumentsDocIdDeleteResponse, DeleteDocumentV1DocumentsDocIdDeleteResponses, DenyV1PairOffersOfferIdDenyPostData, DenyV1PairOffersOfferIdDenyPostError, DenyV1PairOffersOfferIdDenyPostErrors, DenyV1PairOffersOfferIdDenyPostResponse, DenyV1PairOffersOfferIdDenyPostResponses, Device, DevicesV1DevicesGetData, DevicesV1DevicesGetResponse, DevicesV1DevicesGetResponses, DevicesV1PairDevicesGetData, DevicesV1PairDevicesGetResponse, DevicesV1PairDevicesGetResponses, DeviceUnlinked, DevToken, DevTokenIn, DevTokenV1DevTokenPostData, DevTokenV1DevTokenPostError, DevTokenV1DevTokenPostErrors, DevTokenV1DevTokenPostResponse, DevTokenV1DevTokenPostResponses, DocStatus, DocumentOut, DocumentPatch, EventsV1PairOffersOfferIdEventsGetData, EventsV1PairOffersOfferIdEventsGetError, EventsV1PairOffersOfferIdEventsGetErrors, EventsV1PairOffersOfferIdEventsGetResponses, Expenses, ExpensesV1SpacesSpaceIdExpensesGetData, ExpensesV1SpacesSpaceIdExpensesGetError, ExpensesV1SpacesSpaceIdExpensesGetErrors, ExpensesV1SpacesSpaceIdExpensesGetResponse, ExpensesV1SpacesSpaceIdExpensesGetResponses, ExportV1SpacesSpaceIdExportGetData, ExportV1SpacesSpaceIdExportGetError, ExportV1SpacesSpaceIdExportGetErrors, ExportV1SpacesSpaceIdExportGetResponses, GetDocumentV1DocumentsDocIdGetData, GetDocumentV1DocumentsDocIdGetError, GetDocumentV1DocumentsDocIdGetErrors, GetDocumentV1DocumentsDocIdGetResponse, GetDocumentV1DocumentsDocIdGetResponses, GetFileV1DocumentsDocIdFileGetData, GetFileV1DocumentsDocIdFileGetError, GetFileV1DocumentsDocIdFileGetErrors, GetFileV1DocumentsDocIdFileGetResponses, GrantTo, HttpValidationError, Issue, LinkedDeviceOut, ListSpacesV1SpacesGetData, ListSpacesV1SpacesGetResponse, ListSpacesV1SpacesGetResponses, Me, MeV1MeGetData, MeV1MeGetResponse, MeV1MeGetResponses, MonthTotal, NotificationOut, NotificationsOut, OfferCreated, OfferStatus, PairOffer, PairPhoneV1SpacesSpaceIdPairPostData, PairPhoneV1SpacesSpaceIdPairPostError, PairPhoneV1SpacesSpaceIdPairPostErrors, PairPhoneV1SpacesSpaceIdPairPostResponse, PairPhoneV1SpacesSpaceIdPairPostResponses, PatchDocumentV1DocumentsDocIdPatchData, PatchDocumentV1DocumentsDocIdPatchError, PatchDocumentV1DocumentsDocIdPatchErrors, PatchDocumentV1DocumentsDocIdPatchResponse, PatchDocumentV1DocumentsDocIdPatchResponses, QrIn, QrV1QrPostData, QrV1QrPostError, QrV1QrPostErrors, QrV1QrPostResponse, QrV1QrPostResponses, SchemaV1SchemasDocTypeGetData, SchemaV1SchemasDocTypeGetError, SchemaV1SchemasDocTypeGetErrors, SchemaV1SchemasDocTypeGetResponse, SchemaV1SchemasDocTypeGetResponses, SetBudgetsV1SpacesSpaceIdBudgetsPutData, SetBudgetsV1SpacesSpaceIdBudgetsPutError, SetBudgetsV1SpacesSpaceIdBudgetsPutErrors, SetBudgetsV1SpacesSpaceIdBudgetsPutResponse, SetBudgetsV1SpacesSpaceIdBudgetsPutResponses, SpaceIn, SpaceKind, SpaceOut, State, StatusV1PairOffersOfferIdGetData, StatusV1PairOffersOfferIdGetError, StatusV1PairOffersOfferIdGetErrors, StatusV1PairOffersOfferIdGetResponse, StatusV1PairOffersOfferIdGetResponses, Summary, SummaryV1SpacesSpaceIdSummaryGetData, SummaryV1SpacesSpaceIdSummaryGetError, SummaryV1SpacesSpaceIdSummaryGetErrors, SummaryV1SpacesSpaceIdSummaryGetResponse, SummaryV1SpacesSpaceIdSummaryGetResponses, UnlinkV1DevicesDeviceTokenIdDeleteData, UnlinkV1DevicesDeviceTokenIdDeleteError, UnlinkV1DevicesDeviceTokenIdDeleteErrors, UnlinkV1DevicesDeviceTokenIdDeleteResponse, UnlinkV1DevicesDeviceTokenIdDeleteResponses, UnlinkV1PairDevicesDeviceIdDeleteData, UnlinkV1PairDevicesDeviceIdDeleteError, UnlinkV1PairDevicesDeviceIdDeleteErrors, UnlinkV1PairDevicesDeviceIdDeleteResponse, UnlinkV1PairDevicesDeviceIdDeleteResponses, UploadV1SpacesSpaceIdDocumentsPostData, UploadV1SpacesSpaceIdDocumentsPostError, UploadV1SpacesSpaceIdDocumentsPostErrors, UploadV1SpacesSpaceIdDocumentsPostResponse, UploadV1SpacesSpaceIdDocumentsPostResponses, ValidationError } from './types.gen'; +export type { ApproveV1PairOffersOfferIdApprovePostData, ApproveV1PairOffersOfferIdApprovePostError, ApproveV1PairOffersOfferIdApprovePostErrors, ApproveV1PairOffersOfferIdApprovePostResponse, ApproveV1PairOffersOfferIdApprovePostResponses, BodyUploadV1SpacesSpaceIdDocumentsPost, BudgetsIn, CategoriesV1CategoriesGetData, CategoriesV1CategoriesGetResponse, CategoriesV1CategoriesGetResponses, CategorySpend, Claimed, ClaimIn, ClaimV1PairClaimPostData, ClaimV1PairClaimPostError, ClaimV1PairClaimPostErrors, ClaimV1PairClaimPostResponse, ClaimV1PairClaimPostResponses, ClientConfig, ClientConfigV1ConfigGetData, ClientConfigV1ConfigGetResponse, ClientConfigV1ConfigGetResponses, ClientOptions, Collected, CollectV1PairOffersOfferIdCollectPostData, CollectV1PairOffersOfferIdCollectPostError, CollectV1PairOffersOfferIdCollectPostErrors, CollectV1PairOffersOfferIdCollectPostResponse, CollectV1PairOffersOfferIdCollectPostResponses, CreateOfferIn, CreateOfferV1PairOffersPostData, CreateOfferV1PairOffersPostError, CreateOfferV1PairOffersPostErrors, CreateOfferV1PairOffersPostResponse, CreateOfferV1PairOffersPostResponses, CreateSpaceV1SpacesPostData, CreateSpaceV1SpacesPostError, CreateSpaceV1SpacesPostErrors, CreateSpaceV1SpacesPostResponse, CreateSpaceV1SpacesPostResponses, DeleteDocumentV1DocumentsDocIdDeleteData, DeleteDocumentV1DocumentsDocIdDeleteError, DeleteDocumentV1DocumentsDocIdDeleteErrors, DeleteDocumentV1DocumentsDocIdDeleteResponse, DeleteDocumentV1DocumentsDocIdDeleteResponses, DenyV1PairOffersOfferIdDenyPostData, DenyV1PairOffersOfferIdDenyPostError, DenyV1PairOffersOfferIdDenyPostErrors, DenyV1PairOffersOfferIdDenyPostResponse, DenyV1PairOffersOfferIdDenyPostResponses, Device, DevicesV1DevicesGetData, DevicesV1DevicesGetResponse, DevicesV1DevicesGetResponses, DevicesV1PairDevicesGetData, DevicesV1PairDevicesGetResponse, DevicesV1PairDevicesGetResponses, DeviceUnlinked, DevToken, DevTokenIn, DevTokenV1DevTokenPostData, DevTokenV1DevTokenPostError, DevTokenV1DevTokenPostErrors, DevTokenV1DevTokenPostResponse, DevTokenV1DevTokenPostResponses, DocStatus, DocumentOut, DocumentPatch, EmailCodeCheck, EmailCodeCheckV1AuthEmailVerifyPostData, EmailCodeCheckV1AuthEmailVerifyPostError, EmailCodeCheckV1AuthEmailVerifyPostErrors, EmailCodeCheckV1AuthEmailVerifyPostResponse, EmailCodeCheckV1AuthEmailVerifyPostResponses, EmailCodeIn, EmailCodeSent, EmailCodeV1AuthEmailPostData, EmailCodeV1AuthEmailPostError, EmailCodeV1AuthEmailPostErrors, EmailCodeV1AuthEmailPostResponse, EmailCodeV1AuthEmailPostResponses, EventsV1PairOffersOfferIdEventsGetData, EventsV1PairOffersOfferIdEventsGetError, EventsV1PairOffersOfferIdEventsGetErrors, EventsV1PairOffersOfferIdEventsGetResponses, Expenses, ExpensesV1SpacesSpaceIdExpensesGetData, ExpensesV1SpacesSpaceIdExpensesGetError, ExpensesV1SpacesSpaceIdExpensesGetErrors, ExpensesV1SpacesSpaceIdExpensesGetResponse, ExpensesV1SpacesSpaceIdExpensesGetResponses, ExportV1SpacesSpaceIdExportGetData, ExportV1SpacesSpaceIdExportGetError, ExportV1SpacesSpaceIdExportGetErrors, ExportV1SpacesSpaceIdExportGetResponses, GetDocumentV1DocumentsDocIdGetData, GetDocumentV1DocumentsDocIdGetError, GetDocumentV1DocumentsDocIdGetErrors, GetDocumentV1DocumentsDocIdGetResponse, GetDocumentV1DocumentsDocIdGetResponses, GetFileV1DocumentsDocIdFileGetData, GetFileV1DocumentsDocIdFileGetError, GetFileV1DocumentsDocIdFileGetErrors, GetFileV1DocumentsDocIdFileGetResponses, GrantTo, HttpValidationError, Issue, LinkedDeviceOut, ListNotificationsV1NotificationsGetData, ListNotificationsV1NotificationsGetResponse, ListNotificationsV1NotificationsGetResponses, ListSpacesV1SpacesGetData, ListSpacesV1SpacesGetResponse, ListSpacesV1SpacesGetResponses, MarkNotificationsReadV1NotificationsReadPostData, MarkNotificationsReadV1NotificationsReadPostResponse, MarkNotificationsReadV1NotificationsReadPostResponses, Me, MeV1MeGetData, MeV1MeGetResponse, MeV1MeGetResponses, MonthTotal, NotificationOut, NotificationsOut, OfferCreated, OfferStatus, PairOffer, PairPhoneV1SpacesSpaceIdPairPostData, PairPhoneV1SpacesSpaceIdPairPostError, PairPhoneV1SpacesSpaceIdPairPostErrors, PairPhoneV1SpacesSpaceIdPairPostResponse, PairPhoneV1SpacesSpaceIdPairPostResponses, PatchDocumentV1DocumentsDocIdPatchData, PatchDocumentV1DocumentsDocIdPatchError, PatchDocumentV1DocumentsDocIdPatchErrors, PatchDocumentV1DocumentsDocIdPatchResponse, PatchDocumentV1DocumentsDocIdPatchResponses, ProviderFinishV1AuthProviderFinishPostData, ProviderFinishV1AuthProviderFinishPostError, ProviderFinishV1AuthProviderFinishPostErrors, ProviderFinishV1AuthProviderFinishPostResponse, ProviderFinishV1AuthProviderFinishPostResponses, ProviderIn, ProviderReturn, ProviderStartV1AuthProviderPostData, ProviderStartV1AuthProviderPostError, ProviderStartV1AuthProviderPostErrors, ProviderStartV1AuthProviderPostResponse, ProviderStartV1AuthProviderPostResponses, QrIn, QrV1QrPostData, QrV1QrPostError, QrV1QrPostErrors, QrV1QrPostResponse, QrV1QrPostResponses, Redirect, SchemaV1SchemasDocTypeGetData, SchemaV1SchemasDocTypeGetError, SchemaV1SchemasDocTypeGetErrors, SchemaV1SchemasDocTypeGetResponse, SchemaV1SchemasDocTypeGetResponses, SetBudgetsV1SpacesSpaceIdBudgetsPutData, SetBudgetsV1SpacesSpaceIdBudgetsPutError, SetBudgetsV1SpacesSpaceIdBudgetsPutErrors, SetBudgetsV1SpacesSpaceIdBudgetsPutResponse, SetBudgetsV1SpacesSpaceIdBudgetsPutResponses, SpaceIn, SpaceKind, SpaceOut, State, StatusV1PairOffersOfferIdGetData, StatusV1PairOffersOfferIdGetError, StatusV1PairOffersOfferIdGetErrors, StatusV1PairOffersOfferIdGetResponse, StatusV1PairOffersOfferIdGetResponses, Summary, SummaryV1SpacesSpaceIdSummaryGetData, SummaryV1SpacesSpaceIdSummaryGetError, SummaryV1SpacesSpaceIdSummaryGetErrors, SummaryV1SpacesSpaceIdSummaryGetResponse, SummaryV1SpacesSpaceIdSummaryGetResponses, UnlinkV1DevicesDeviceTokenIdDeleteData, UnlinkV1DevicesDeviceTokenIdDeleteError, UnlinkV1DevicesDeviceTokenIdDeleteErrors, UnlinkV1DevicesDeviceTokenIdDeleteResponse, UnlinkV1DevicesDeviceTokenIdDeleteResponses, UnlinkV1PairDevicesDeviceIdDeleteData, UnlinkV1PairDevicesDeviceIdDeleteError, UnlinkV1PairDevicesDeviceIdDeleteErrors, UnlinkV1PairDevicesDeviceIdDeleteResponse, UnlinkV1PairDevicesDeviceIdDeleteResponses, UploadV1SpacesSpaceIdDocumentsPostData, UploadV1SpacesSpaceIdDocumentsPostError, UploadV1SpacesSpaceIdDocumentsPostErrors, UploadV1SpacesSpaceIdDocumentsPostResponse, UploadV1SpacesSpaceIdDocumentsPostResponses, ValidationError } from './types.gen'; diff --git a/src/api/generated/types.gen.ts b/src/api/generated/types.gen.ts index 7d7168c..6db9936 100644 --- a/src/api/generated/types.gen.ts +++ b/src/api/generated/types.gen.ts @@ -125,6 +125,10 @@ export type ClientConfig = { * Scopes */ scopes?: string | null; + /** + * Login Methods + */ + login_methods?: Array<'email' | 'google' | 'apple'>; }; /** @@ -319,6 +323,60 @@ export type DocumentPatch = { confirmed?: boolean; }; +/** + * EmailCodeCheck + */ +export type EmailCodeCheck = { + /** + * Flow + */ + flow: string; + /** + * Code + */ + code: string; +}; + +/** + * EmailCodeIn + */ +export type EmailCodeIn = { + /** + * Code Challenge + */ + code_challenge: string; + /** + * State + */ + state: string; + /** + * Email + */ + email: string; +}; + +/** + * EmailCodeSent + */ +export type EmailCodeSent = { + /** + * Flow + */ + flow: string; + /** + * Email + */ + email: string; + /** + * Expires In + */ + expires_in: number; + /** + * Resend After + */ + resend_after: number; +}; + /** * Expenses */ @@ -429,10 +487,25 @@ export type MonthTotal = { * NotificationOut */ export type NotificationOut = { + /** + * Id + */ id: string; + /** + * Title + */ title: string; + /** + * Detail + */ detail: string; + /** + * Href + */ href: string; + /** + * Unread + */ unread: boolean; }; @@ -440,7 +513,13 @@ export type NotificationOut = { * NotificationsOut */ export type NotificationsOut = { + /** + * Items + */ items: Array; + /** + * Unread Count + */ unread_count: number; }; @@ -528,6 +607,44 @@ export type PairOffer = { qr_url: string; }; +/** + * ProviderIn + */ +export type ProviderIn = { + /** + * Code Challenge + */ + code_challenge: string; + /** + * State + */ + state: string; + /** + * Provider + */ + provider: 'google' | 'apple'; +}; + +/** + * ProviderReturn + * + * What Zitadel appended to the return URL after Google or Apple. + */ +export type ProviderReturn = { + /** + * Flow + */ + flow: string; + /** + * Intent Id + */ + intent_id: string; + /** + * Intent Token + */ + intent_token: string; +}; + /** * QrIn */ @@ -538,6 +655,18 @@ export type QrIn = { data: string; }; +/** + * Redirect + * + * Send the browser here next. + */ +export type Redirect = { + /** + * Url + */ + url: string; +}; + /** * SpaceIn */ @@ -649,6 +778,38 @@ export type ValidationError = { }; }; +export type ListNotificationsV1NotificationsGetData = { + body?: never; + path?: never; + query?: never; + url: '/v1/notifications'; +}; + +export type ListNotificationsV1NotificationsGetResponses = { + /** + * Successful Response + */ + 200: NotificationsOut; +}; + +export type ListNotificationsV1NotificationsGetResponse = ListNotificationsV1NotificationsGetResponses[keyof ListNotificationsV1NotificationsGetResponses]; + +export type MarkNotificationsReadV1NotificationsReadPostData = { + body?: never; + path?: never; + query?: never; + url: '/v1/notifications/read'; +}; + +export type MarkNotificationsReadV1NotificationsReadPostResponses = { + /** + * Successful Response + */ + 200: NotificationsOut; +}; + +export type MarkNotificationsReadV1NotificationsReadPostResponse = MarkNotificationsReadV1NotificationsReadPostResponses[keyof MarkNotificationsReadV1NotificationsReadPostResponses]; + export type CreateOfferV1PairOffersPostData = { body: CreateOfferIn; path?: never; @@ -1471,28 +1632,102 @@ export type QrV1QrPostResponses = { export type QrV1QrPostResponse = QrV1QrPostResponses[keyof QrV1QrPostResponses]; -export type ListNotificationsV1NotificationsGetData = { - body?: never; +export type EmailCodeV1AuthEmailPostData = { + body: EmailCodeIn; path?: never; query?: never; - url: '/v1/notifications'; + url: '/v1/auth/email'; }; -export type ListNotificationsV1NotificationsGetResponses = { - 200: NotificationsOut; +export type EmailCodeV1AuthEmailPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; }; -export type ListNotificationsV1NotificationsGetResponse = ListNotificationsV1NotificationsGetResponses[keyof ListNotificationsV1NotificationsGetResponses]; +export type EmailCodeV1AuthEmailPostError = EmailCodeV1AuthEmailPostErrors[keyof EmailCodeV1AuthEmailPostErrors]; -export type MarkNotificationsReadV1NotificationsReadPostData = { - body?: never; +export type EmailCodeV1AuthEmailPostResponses = { + /** + * Successful Response + */ + 200: EmailCodeSent; +}; + +export type EmailCodeV1AuthEmailPostResponse = EmailCodeV1AuthEmailPostResponses[keyof EmailCodeV1AuthEmailPostResponses]; + +export type EmailCodeCheckV1AuthEmailVerifyPostData = { + body: EmailCodeCheck; path?: never; query?: never; - url: '/v1/notifications/read'; + url: '/v1/auth/email/verify'; }; -export type MarkNotificationsReadV1NotificationsReadPostResponses = { - 200: NotificationsOut; +export type EmailCodeCheckV1AuthEmailVerifyPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; }; -export type MarkNotificationsReadV1NotificationsReadPostResponse = MarkNotificationsReadV1NotificationsReadPostResponses[keyof MarkNotificationsReadV1NotificationsReadPostResponses]; +export type EmailCodeCheckV1AuthEmailVerifyPostError = EmailCodeCheckV1AuthEmailVerifyPostErrors[keyof EmailCodeCheckV1AuthEmailVerifyPostErrors]; + +export type EmailCodeCheckV1AuthEmailVerifyPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type EmailCodeCheckV1AuthEmailVerifyPostResponse = EmailCodeCheckV1AuthEmailVerifyPostResponses[keyof EmailCodeCheckV1AuthEmailVerifyPostResponses]; + +export type ProviderStartV1AuthProviderPostData = { + body: ProviderIn; + path?: never; + query?: never; + url: '/v1/auth/provider'; +}; + +export type ProviderStartV1AuthProviderPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ProviderStartV1AuthProviderPostError = ProviderStartV1AuthProviderPostErrors[keyof ProviderStartV1AuthProviderPostErrors]; + +export type ProviderStartV1AuthProviderPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type ProviderStartV1AuthProviderPostResponse = ProviderStartV1AuthProviderPostResponses[keyof ProviderStartV1AuthProviderPostResponses]; + +export type ProviderFinishV1AuthProviderFinishPostData = { + body: ProviderReturn; + path?: never; + query?: never; + url: '/v1/auth/provider/finish'; +}; + +export type ProviderFinishV1AuthProviderFinishPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ProviderFinishV1AuthProviderFinishPostError = ProviderFinishV1AuthProviderFinishPostErrors[keyof ProviderFinishV1AuthProviderFinishPostErrors]; + +export type ProviderFinishV1AuthProviderFinishPostResponses = { + /** + * Successful Response + */ + 200: Redirect; +}; + +export type ProviderFinishV1AuthProviderFinishPostResponse = ProviderFinishV1AuthProviderFinishPostResponses[keyof ProviderFinishV1AuthProviderFinishPostResponses]; diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 300a84c..1ec610f 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -115,6 +115,12 @@ export function AppShell({ children }: { children: ReactNode }) { } setAuthorized(false); setChecked(true); + // Back from signing in (?code=...): the sign-in page swaps the code for tokens. + const back = new URLSearchParams(window.location.search); + if (back.has("code") || back.has("error")) { + router.replace(`/connect${window.location.search}`); + return; + } const returnTo = pathname.startsWith("/") ? pathname : "/"; router.replace(`/connect?returnTo=${encodeURIComponent(returnTo)}`); }, [isPublicRoute, pathname, router]); diff --git a/src/oidc.ts b/src/oidc.ts index 3825ea4..f694c4a 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -1,6 +1,11 @@ // Sign-in with an OIDC provider (Zitadel in production): Authorization Code flow with // PKCE, so the browser needs no client secret. The provider redirects back to this page // with ?code=...; finish() swaps it for tokens. +// +// The sign-in page can also run the sign-in itself (email code, Google, Apple): begin() +// gives the API the PKCE challenge, and the API hands back the same ?code= redirect. + +import { store } from "./store"; export interface OidcConfig { issuer: string; @@ -62,19 +67,33 @@ async function tokenRequest(cfg: OidcConfig, params: Record): Pr return r.json() as Promise; } +export interface Pkce { + code_challenge: string; + state: string; +} + +/** + * Start a sign-in: a new verifier and state, kept until the browser comes back. Kept in + * localStorage, not sessionStorage, so the link in a sign-in email works in a new tab. + */ +export async function begin(): Promise { + const verifier = randomString(32); + const state = randomString(16); + store.set(PENDING, JSON.stringify({ verifier, state })); + return { code_challenge: await pkceChallenge(verifier), state }; +} + /** Go to the provider's sign-in page. `prompt: "create"` opens registration instead. */ export async function login(cfg: OidcConfig, { prompt }: { prompt?: string } = {}): Promise { const { authorization_endpoint } = await discover(cfg.issuer); - const verifier = randomString(32); - const state = randomString(16); - sessionStorage.setItem(PENDING, JSON.stringify({ verifier, state })); + const { code_challenge, state } = await begin(); const q = new URLSearchParams({ response_type: "code", client_id: cfg.client_id, redirect_uri: redirectUri(), scope: cfg.scopes, state, - code_challenge: await pkceChallenge(verifier), + code_challenge, code_challenge_method: "S256", }); if (prompt) q.set("prompt", prompt); @@ -86,11 +105,11 @@ export async function finish(cfg: OidcConfig): Promise { const q = new URLSearchParams(location.search); if (!q.has("code") && !q.has("error")) return null; history.replaceState(null, "", location.pathname + location.hash); // keep the code out of history - const pending = JSON.parse(sessionStorage.getItem(PENDING) ?? "null") as { + const pending = JSON.parse(store.get(PENDING) ?? "null") as { verifier: string; state: string; } | null; - sessionStorage.removeItem(PENDING); + store.set(PENDING, null); if (q.has("error")) throw new Error(q.get("error_description") || "sign-in was cancelled"); const code = q.get("code"); if (!pending || !code || pending.state !== q.get("state")) throw new Error("sign-in expired; try again"); diff --git a/test/oidc.test.ts b/test/oidc.test.ts index 878bd98..4854439 100644 --- a/test/oidc.test.ts +++ b/test/oidc.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { b64url, finish, pkceChallenge } from "../src/oidc"; +import { b64url, begin, finish, login, pkceChallenge } from "../src/oidc"; describe("PKCE", () => { it("matches the RFC 7636 example", async () => { @@ -16,7 +16,7 @@ describe("PKCE", () => { describe("finish", () => { const cfg = { issuer: "https://auth.example.com", client_id: "web", scopes: "openid" }; afterEach(() => { - sessionStorage.clear(); + localStorage.clear(); history.replaceState(null, "", "/"); }); @@ -25,14 +25,14 @@ describe("finish", () => { }); it("rejects a return whose state doesn't match (a forged or stale sign-in)", async () => { - sessionStorage.setItem("ss.oidc.pending", JSON.stringify({ verifier: "v", state: "expected" })); + localStorage.setItem("ss.oidc.pending", JSON.stringify({ verifier: "v", state: "expected" })); history.replaceState(null, "", "/?code=abc&state=attacker"); await expect(finish(cfg)).rejects.toThrow("sign-in expired"); expect(location.search).toBe(""); // the code is removed from the address bar either way }); it("swaps the code for tokens, sending the PKCE verifier", async () => { - sessionStorage.setItem("ss.oidc.pending", JSON.stringify({ verifier: "the-verifier", state: "s1" })); + localStorage.setItem("ss.oidc.pending", JSON.stringify({ verifier: "the-verifier", state: "s1" })); history.replaceState(null, "", "/?code=abc&state=s1"); const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { if (url.endsWith("/.well-known/openid-configuration")) @@ -48,3 +48,38 @@ describe("finish", () => { vi.unstubAllGlobals(); }); }); + +describe("begin", () => { + afterEach(() => localStorage.clear()); + + it("keeps the verifier and gives out only its challenge", async () => { + const { code_challenge, state } = await begin(); + const pending = JSON.parse(localStorage.getItem("ss.oidc.pending") ?? "{}"); + expect(pending.state).toBe(state); + expect(code_challenge).toBe(await pkceChallenge(pending.verifier)); + expect(code_challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); +}); + +describe("login", () => { + afterEach(() => { + localStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("goes to the provider with a PKCE challenge", async () => { + const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ authorization_endpoint: "https://auth.example.com/authorize" })), + ); + const cfg = { issuer: "https://auth.example.com", client_id: "web", scopes: "openid" }; + + await login(cfg); + + expect(assign).toHaveBeenCalledOnce(); + expect(assign.mock.calls[0][0]).toContain("code_challenge_method=S256"); + expect(assign.mock.calls[0][0]).not.toContain("login_hint"); + }); +});