From e691a6ecccbeceafdcbe6f7f598c5f7cbc12b243 Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 20 Aug 2026 08:51:50 +0200 Subject: [PATCH 01/15] feat(app): design the guest auth screens for Ory Kratos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-one screens covering the guest path, email one-time-code login, registration and verification, recovery, account settings and the error states — built as real components rather than pictures of them, so the wiring pass replaces prop values with flow data instead of markup. A dev-only canvas at /design/auth renders every screen and state in both themes side by side. It ships in no build. authCopy.ts holds the English default for each string keyed by the i18n key it will use, and useAuthCopy() prefers a translation when the language docs carry one, so the screens read correctly before any key exists. docs/temp_kratos-guest-auth-ui.md records what "guest" means here (no Kratos session — the API already serves anonymous callers), why the pages belong in the PWA rather than a standalone self-service UI, and the screen-to-flow mapping. --- .../auth/kratos/AccountSettingsScreen.vue | 163 +++++++ .../components/auth/kratos/AuthCodeInput.vue | 85 ++++ .../components/auth/kratos/AuthDoneScreen.vue | 33 ++ .../auth/kratos/AuthErrorScreen.vue | 76 ++++ .../components/auth/kratos/AuthMessage.vue | 37 ++ app/src/components/auth/kratos/AuthShell.vue | 57 +++ .../components/auth/kratos/AuthTextField.vue | 63 +++ .../auth/kratos/EmailIdentifierScreen.vue | 65 +++ .../components/auth/kratos/GuestGateCard.vue | 45 ++ .../auth/kratos/GuestUpgradeScreen.vue | 61 +++ .../auth/kratos/GuestWelcomeScreen.vue | 49 +++ .../auth/kratos/OneTimeCodeScreen.vue | 99 +++++ .../auth/kratos/RegistrationScreen.vue | 88 ++++ .../auth/kratos/SignInMethodsScreen.vue | 120 +++++ app/src/components/auth/kratos/authCopy.ts | 101 +++++ .../components/auth/kratos/screens.spec.ts | 89 ++++ app/src/components/auth/kratos/types.ts | 11 + app/src/components/auth/kratos/useAuthCopy.ts | 23 + app/src/pages/design/AuthArtboard.vue | 32 ++ app/src/pages/design/AuthDesignPage.spec.ts | 21 + app/src/pages/design/AuthDesignPage.vue | 412 ++++++++++++++++++ app/src/router/routes.ts | 19 + docs/temp_kratos-guest-auth-ui.md | 111 +++++ 23 files changed, 1860 insertions(+) create mode 100644 app/src/components/auth/kratos/AccountSettingsScreen.vue create mode 100644 app/src/components/auth/kratos/AuthCodeInput.vue create mode 100644 app/src/components/auth/kratos/AuthDoneScreen.vue create mode 100644 app/src/components/auth/kratos/AuthErrorScreen.vue create mode 100644 app/src/components/auth/kratos/AuthMessage.vue create mode 100644 app/src/components/auth/kratos/AuthShell.vue create mode 100644 app/src/components/auth/kratos/AuthTextField.vue create mode 100644 app/src/components/auth/kratos/EmailIdentifierScreen.vue create mode 100644 app/src/components/auth/kratos/GuestGateCard.vue create mode 100644 app/src/components/auth/kratos/GuestUpgradeScreen.vue create mode 100644 app/src/components/auth/kratos/GuestWelcomeScreen.vue create mode 100644 app/src/components/auth/kratos/OneTimeCodeScreen.vue create mode 100644 app/src/components/auth/kratos/RegistrationScreen.vue create mode 100644 app/src/components/auth/kratos/SignInMethodsScreen.vue create mode 100644 app/src/components/auth/kratos/authCopy.ts create mode 100644 app/src/components/auth/kratos/screens.spec.ts create mode 100644 app/src/components/auth/kratos/types.ts create mode 100644 app/src/components/auth/kratos/useAuthCopy.ts create mode 100644 app/src/pages/design/AuthArtboard.vue create mode 100644 app/src/pages/design/AuthDesignPage.spec.ts create mode 100644 app/src/pages/design/AuthDesignPage.vue create mode 100644 docs/temp_kratos-guest-auth-ui.md diff --git a/app/src/components/auth/kratos/AccountSettingsScreen.vue b/app/src/components/auth/kratos/AccountSettingsScreen.vue new file mode 100644 index 0000000000..44971f013d --- /dev/null +++ b/app/src/components/auth/kratos/AccountSettingsScreen.vue @@ -0,0 +1,163 @@ + + + diff --git a/app/src/components/auth/kratos/AuthCodeInput.vue b/app/src/components/auth/kratos/AuthCodeInput.vue new file mode 100644 index 0000000000..3863807d79 --- /dev/null +++ b/app/src/components/auth/kratos/AuthCodeInput.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/src/components/auth/kratos/AuthDoneScreen.vue b/app/src/components/auth/kratos/AuthDoneScreen.vue new file mode 100644 index 0000000000..1011c0fa9b --- /dev/null +++ b/app/src/components/auth/kratos/AuthDoneScreen.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/src/components/auth/kratos/AuthErrorScreen.vue b/app/src/components/auth/kratos/AuthErrorScreen.vue new file mode 100644 index 0000000000..d6fe973dc1 --- /dev/null +++ b/app/src/components/auth/kratos/AuthErrorScreen.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/src/components/auth/kratos/AuthMessage.vue b/app/src/components/auth/kratos/AuthMessage.vue new file mode 100644 index 0000000000..7d60617628 --- /dev/null +++ b/app/src/components/auth/kratos/AuthMessage.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/src/components/auth/kratos/AuthShell.vue b/app/src/components/auth/kratos/AuthShell.vue new file mode 100644 index 0000000000..cd60797270 --- /dev/null +++ b/app/src/components/auth/kratos/AuthShell.vue @@ -0,0 +1,57 @@ + + + diff --git a/app/src/components/auth/kratos/AuthTextField.vue b/app/src/components/auth/kratos/AuthTextField.vue new file mode 100644 index 0000000000..0b4c4eabcc --- /dev/null +++ b/app/src/components/auth/kratos/AuthTextField.vue @@ -0,0 +1,63 @@ + + + diff --git a/app/src/components/auth/kratos/EmailIdentifierScreen.vue b/app/src/components/auth/kratos/EmailIdentifierScreen.vue new file mode 100644 index 0000000000..cb7355d90f --- /dev/null +++ b/app/src/components/auth/kratos/EmailIdentifierScreen.vue @@ -0,0 +1,65 @@ + + + diff --git a/app/src/components/auth/kratos/GuestGateCard.vue b/app/src/components/auth/kratos/GuestGateCard.vue new file mode 100644 index 0000000000..00b5575952 --- /dev/null +++ b/app/src/components/auth/kratos/GuestGateCard.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/src/components/auth/kratos/GuestUpgradeScreen.vue b/app/src/components/auth/kratos/GuestUpgradeScreen.vue new file mode 100644 index 0000000000..82885036e0 --- /dev/null +++ b/app/src/components/auth/kratos/GuestUpgradeScreen.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/src/components/auth/kratos/GuestWelcomeScreen.vue b/app/src/components/auth/kratos/GuestWelcomeScreen.vue new file mode 100644 index 0000000000..fac5876350 --- /dev/null +++ b/app/src/components/auth/kratos/GuestWelcomeScreen.vue @@ -0,0 +1,49 @@ + + + diff --git a/app/src/components/auth/kratos/OneTimeCodeScreen.vue b/app/src/components/auth/kratos/OneTimeCodeScreen.vue new file mode 100644 index 0000000000..02eea9c703 --- /dev/null +++ b/app/src/components/auth/kratos/OneTimeCodeScreen.vue @@ -0,0 +1,99 @@ + + + diff --git a/app/src/components/auth/kratos/RegistrationScreen.vue b/app/src/components/auth/kratos/RegistrationScreen.vue new file mode 100644 index 0000000000..fdc80cdcc1 --- /dev/null +++ b/app/src/components/auth/kratos/RegistrationScreen.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/src/components/auth/kratos/SignInMethodsScreen.vue b/app/src/components/auth/kratos/SignInMethodsScreen.vue new file mode 100644 index 0000000000..12fae2171d --- /dev/null +++ b/app/src/components/auth/kratos/SignInMethodsScreen.vue @@ -0,0 +1,120 @@ + + + diff --git a/app/src/components/auth/kratos/authCopy.ts b/app/src/components/auth/kratos/authCopy.ts new file mode 100644 index 0000000000..64f21192bc --- /dev/null +++ b/app/src/components/auth/kratos/authCopy.ts @@ -0,0 +1,101 @@ +/** + * Default English copy for the Kratos auth screens, keyed by the i18n key each + * string uses once the language docs carry it. Until then `useAuthCopy` falls + * back to the value here, so the screens read correctly before translation. + */ +export const authCopy = { + "auth.methods.title": "Sign in", + "auth.methods.subtitle": + "Use your email address, or continue with an account you already have.", + "auth.methods.email": "Continue with email", + "auth.methods.divider": "or", + "auth.methods.guest": "Continue without an account", + "auth.methods.none": "No sign-in methods are available right now.", + + "auth.email.title": "What's your email address?", + "auth.email.subtitle": "We'll send you a 6-digit code. No password to remember.", + "auth.email.label": "Email address", + "auth.email.placeholder": "you@example.com", + "auth.email.submit": "Send me a code", + "auth.email.invalid": "That doesn't look like an email address.", + + "auth.code.title": "Enter your code", + "auth.code.subtitle": "We sent a 6-digit code to {email}.", + "auth.code.label": "Verification code", + "auth.code.submit": "Sign in", + "auth.code.resend": "Send a new code", + "auth.code.resend_in": "You can ask for a new code in {seconds}s", + "auth.code.change_email": "Use a different email address", + "auth.code.invalid": "That code isn't right. Check it and try again.", + "auth.code.expired": "This code has expired. Ask for a new one.", + "auth.code.too_many": "Too many tries. Wait a minute, then ask for a new code.", + + "auth.register.title": "Create your account", + "auth.register.subtitle": "Your saved items and progress move with you, on every device.", + "auth.register.name_label": "Name", + "auth.register.name_placeholder": "How should we greet you?", + "auth.register.submit": "Create account", + "auth.register.have_account": "I already have an account", + + "auth.verify.title": "Confirm your email", + "auth.verify.subtitle": + "Enter the 6-digit code we sent to {email} to finish setting up your account.", + "auth.verify.submit": "Confirm", + "auth.verify.done_title": "You're all set", + "auth.verify.done_subtitle": "Your email is confirmed and you're signed in.", + "auth.verify.done_continue": "Start reading", + + "auth.upgrade.title": "Keep what you've saved", + "auth.upgrade.subtitle": + "You've been reading as a guest. Create an account and everything saved on this device comes with you.", + "auth.upgrade.bookmarks": "{count} saved items", + "auth.upgrade.progress": "Your reading and watching progress", + "auth.upgrade.submit": "Create an account", + "auth.upgrade.later": "Not now", + + "auth.recovery.title": "Recover your account", + "auth.recovery.subtitle": + "Enter the email you signed up with and we'll send you a code to get back in.", + "auth.recovery.submit": "Send recovery code", + "auth.recovery.sent": "If that address has an account, a code is on its way.", + + "auth.settings.title": "Account", + "auth.settings.email_section": "Email address", + "auth.settings.methods_section": "Sign-in methods", + "auth.settings.sessions_section": "Where you're signed in", + "auth.settings.this_device": "This device", + "auth.settings.sign_out_others": "Sign out everywhere else", + "auth.settings.change": "Change", + "auth.settings.link": "Link", + "auth.settings.unlink": "Unlink", + "auth.settings.delete": "Delete my account", + + "auth.guest.title": "Welcome", + "auth.guest.subtitle": + "Sign in to save what you read across your devices — or look around first.", + "auth.guest.sign_in": "Sign in", + "auth.guest.continue": "Continue without an account", + "auth.guest.badge": "Guest", + + "auth.gate.bookmarks_title": "Save this for later", + "auth.gate.bookmarks_body": + "Bookmarks stay on this device until you sign in. Sign in to keep them everywhere.", + "auth.gate.sign_in": "Sign in", + "auth.gate.dismiss": "Not now", + + "auth.error.title": "Something went wrong", + "auth.error.expired_title": "That took a little too long", + "auth.error.expired_body": + "For your safety this sign-in link expired. Start again and it'll only take a moment.", + "auth.error.offline_title": "You're offline", + "auth.error.offline_body": + "Signing in needs a connection. Everything already downloaded is still here to read.", + "auth.error.restart": "Start again", + "auth.error.back": "Back to reading", + "auth.error.reference": "Reference: {id}", + + "auth.common.back": "Back", + "auth.common.privacy_note": "By continuing you accept our privacy policy.", +} as const; + +export type AuthCopyKey = keyof typeof authCopy; diff --git a/app/src/components/auth/kratos/screens.spec.ts b/app/src/components/auth/kratos/screens.spec.ts new file mode 100644 index 0000000000..2fae18f098 --- /dev/null +++ b/app/src/components/auth/kratos/screens.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { authCopy } from "./authCopy"; +import AuthCodeInput from "./AuthCodeInput.vue"; +import SignInMethodsScreen from "./SignInMethodsScreen.vue"; +import EmailIdentifierScreen from "./EmailIdentifierScreen.vue"; +import OneTimeCodeScreen from "./OneTimeCodeScreen.vue"; +import RegistrationScreen from "./RegistrationScreen.vue"; +import AuthDoneScreen from "./AuthDoneScreen.vue"; +import GuestWelcomeScreen from "./GuestWelcomeScreen.vue"; +import GuestGateCard from "./GuestGateCard.vue"; +import GuestUpgradeScreen from "./GuestUpgradeScreen.vue"; +import AccountSettingsScreen from "./AccountSettingsScreen.vue"; +import AuthErrorScreen from "./AuthErrorScreen.vue"; + +// No auth key is translated yet, so `te` is false everywhere and the screens +// fall back to the English defaults in authCopy — which is what they render in +// production until the language docs carry the keys. +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key, te: () => false }), +})); + +describe("Kratos auth screens", () => { + it("renders every screen with its default copy", () => { + const cases: Array<[string, ReturnType]> = [ + [authCopy["auth.methods.title"], mount(SignInMethodsScreen)], + [authCopy["auth.email.title"], mount(EmailIdentifierScreen)], + [ + authCopy["auth.code.title"], + mount(OneTimeCodeScreen, { props: { email: "a@b.com" } }), + ], + [authCopy["auth.register.title"], mount(RegistrationScreen)], + [authCopy["auth.verify.done_title"], mount(AuthDoneScreen)], + [authCopy["auth.guest.title"], mount(GuestWelcomeScreen)], + [authCopy["auth.gate.bookmarks_title"], mount(GuestGateCard)], + [authCopy["auth.upgrade.title"], mount(GuestUpgradeScreen)], + [ + authCopy["auth.settings.title"], + mount(AccountSettingsScreen, { props: { email: "a@b.com" } }), + ], + [ + authCopy["auth.error.expired_title"], + mount(AuthErrorScreen, { props: { kind: "expired" } }), + ], + ]; + + for (const [heading, wrapper] of cases) { + expect(wrapper.text()).toContain(heading); + } + }); + + it("interpolates the address into the code screen's subtitle", () => { + const wrapper = mount(OneTimeCodeScreen, { props: { email: "johan@example.com" } }); + expect(wrapper.text()).toContain("johan@example.com"); + expect(wrapper.text()).not.toContain("{email}"); + }); + + it("offers a resend only once the countdown has run out", async () => { + const waiting = mount(OneTimeCodeScreen, { props: { email: "a@b.com", resendIn: 20 } }); + expect(waiting.text()).toContain("20s"); + expect(waiting.text()).not.toContain(authCopy["auth.code.resend"]); + + const ready = mount(OneTimeCodeScreen, { props: { email: "a@b.com", resendIn: 0 } }); + expect(ready.text()).toContain(authCopy["auth.code.resend"]); + }); + + it("spreads a pasted code across the boxes and reports completion", async () => { + const wrapper = mount(AuthCodeInput, { props: { label: "Code" } }); + await wrapper.find("input").trigger("paste", { + clipboardData: { getData: () => "1 2 3-456" }, + }); + + expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual(["123456"]); + expect(wrapper.emitted("complete")?.at(-1)).toEqual(["123456"]); + }); + + it("keeps continuing as a guest out of the method list", () => { + const wrapper = mount(SignInMethodsScreen, { + props: { providers: [{ id: "bcc", label: "Continue with BCC" }] }, + }); + const methodButtons = wrapper.findAll("button"); + const guest = methodButtons.find((b) => b.text() === authCopy["auth.methods.guest"]); + + expect(guest).toBeDefined(); + // A dismissal must not wear the same clothes as a sign-in method. + expect(guest!.classes().join(" ")).toContain("underline"); + expect(guest!.classes().join(" ")).not.toContain("border"); + }); +}); diff --git a/app/src/components/auth/kratos/types.ts b/app/src/components/auth/kratos/types.ts new file mode 100644 index 0000000000..93eedbaff6 --- /dev/null +++ b/app/src/components/auth/kratos/types.ts @@ -0,0 +1,11 @@ +/** The non-secret provider fields these screens need, as carried on an `AuthProviderDto`. */ +export type AuthProviderOption = { + id: string; + label: string; + iconUrl?: string; + backgroundColor?: string; + textColor?: string; +}; + +/** Kratos message types, as they arrive on `ui.messages[].type`. */ +export type AuthMessageType = "info" | "error" | "success"; diff --git a/app/src/components/auth/kratos/useAuthCopy.ts b/app/src/components/auth/kratos/useAuthCopy.ts new file mode 100644 index 0000000000..e46aab3cfd --- /dev/null +++ b/app/src/components/auth/kratos/useAuthCopy.ts @@ -0,0 +1,23 @@ +import { useI18n } from "vue-i18n"; +import { authCopy, type AuthCopyKey } from "./authCopy"; + +type Params = Record; + +function interpolate(template: string, params?: Params): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name) => + name in params ? String(params[name]) : match, + ); +} + +/** + * Resolves an auth string from the synced language docs, falling back to the + * English default in `authCopy` while a key has no translation yet. Screens can + * therefore be designed and reviewed before the keys exist. + */ +export function useAuthCopy() { + const { t, te } = useI18n(); + + return (key: AuthCopyKey, params?: Params): string => + te(key) ? t(key, params ?? {}) : interpolate(authCopy[key], params); +} diff --git a/app/src/pages/design/AuthArtboard.vue b/app/src/pages/design/AuthArtboard.vue new file mode 100644 index 0000000000..b548b5aa35 --- /dev/null +++ b/app/src/pages/design/AuthArtboard.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/src/pages/design/AuthDesignPage.spec.ts b/app/src/pages/design/AuthDesignPage.spec.ts new file mode 100644 index 0000000000..92c8ac6d71 --- /dev/null +++ b/app/src/pages/design/AuthDesignPage.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import AuthDesignPage from "./AuthDesignPage.vue"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (key: string) => key, te: () => false }), +})); + +describe("AuthDesignPage", () => { + it("renders every artboard in both themes and leaves the app's theme class alone", () => { + document.documentElement.classList.add("dark"); + const wrapper = mount(AuthDesignPage, { attachTo: document.body }); + + // 21 screens, drawn light and dark. + expect(wrapper.findAll("figure")).toHaveLength(42); + expect(document.documentElement.classList.contains("dark")).toBe(false); + + wrapper.unmount(); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); +}); diff --git a/app/src/pages/design/AuthDesignPage.vue b/app/src/pages/design/AuthDesignPage.vue new file mode 100644 index 0000000000..a87b91d108 --- /dev/null +++ b/app/src/pages/design/AuthDesignPage.vue @@ -0,0 +1,412 @@ + + + diff --git a/app/src/router/routes.ts b/app/src/router/routes.ts index e00f1a2712..5c1575da1b 100644 --- a/app/src/router/routes.ts +++ b/app/src/router/routes.ts @@ -10,6 +10,24 @@ const SettingsPage = () => import("@/pages/SettingsPage.vue"); const BookmarksPage = () => import("@/pages/BookmarksPage.vue"); const SingleContent = () => import("@/pages/SingleContent/SingleContent.vue"); const NotFoundPage = () => import("@/pages/NotFoundPage.vue"); +const AuthDesignPage = () => import("@/pages/design/AuthDesignPage.vue"); + +/** + * Design canvas for the Kratos auth screens. Development only — it renders every + * screen and state side by side and ships in no build. + */ +const designRoutes: RouteRecordRaw[] = import.meta.env.DEV + ? [ + { + path: "/design/auth", + component: AuthDesignPage, + name: "design-auth", + meta: { + analyticsIgnore: true, + }, + }, + ] + : []; /** * The shared route table for both the normal SPA entry and the web/SSG entry. `meta.prerender: true` marks public, crawlable routes the web build emits as static HTML; dynamic content slugs are enumerated from the API at build time. @@ -77,6 +95,7 @@ export const routes: RouteRecordRaw[] = [ title: "title.bookmarks", }, }, + ...designRoutes, // Note that this route should always come after all defined routes, // to prevent wrongly configured slugs from taking over pages { diff --git a/docs/temp_kratos-guest-auth-ui.md b/docs/temp_kratos-guest-auth-ui.md new file mode 100644 index 0000000000..2f2d6e987f --- /dev/null +++ b/docs/temp_kratos-guest-auth-ui.md @@ -0,0 +1,111 @@ +# Guest auth on Ory Kratos — UI design + +Working doc for the app-side login screens. The screens themselves are real components in +`app/src/components/auth/kratos/`; run `npm run dev` in `app/` and open `/design/auth` to +see all 21 of them, light and dark, side by side. + +## What "guest" means + +Kratos has no anonymous identity, and it should not be made to grow one. A guest here is +simply **a visitor with no Kratos session**. The API already handles that case: an +unauthenticated request resolves through `AuthIdentityService.resolveOrDefault`, which +returns `status: "anonymous"` with the groups from provider-less `AutoGroupMappings`. Guest +browsing needs no new server work at all. + +What Kratos adds is a way for a guest to _stop_ being one without leaving the app: email +plus a one-time code, no password to invent, no third-party account required. + +Two consequences the design is built around: + +- **"Continue without an account" is a dismissal, not a sign-in method.** It is a text link + under the card, never a third button in the method list. `screens.spec.ts` pins that. +- **Signing in moves nothing.** Bookmarks and progress live in this device's IndexedDB. The + guest→account screen says what will travel with the account rather than implying a + server-side merge that does not exist yet. If per-user bookmark sync lands later, that + screen is where the merge is announced — the copy is already shaped for it. + +If identified guests are ever a real requirement (device-scoped identity now, claim it from +another device later), the alternative is a credential-less `guest` identity schema created +through the Kratos admin API from the Luminary API. It costs an identity row per device, +gives no way to authenticate _back_ into that identity, and puts every one of those rows +inside the GDPR deletion story. Not worth it without a concrete need. + +## Where the pages live — recommendation + +**Put them in the PWA as routes** (`/login`, `/verify`, `/recovery`, `/account`), and point +Kratos's `selfservice.flows.*.ui_url` at them. Not a standalone self-service UI. + +Reasons, in the order they matter here: + +1. **Translations.** Every user-facing string in this app comes from synced language docs + through `vue-i18n`. A standalone UI cannot reach them, so the most-seen screens in the + product would need a second translation pipeline. Screens in the app use the same `t()` + as everything else. +2. **The installed PWA.** Redirecting to another origin drops the user out of the installed + shell — on iOS standalone mode, into a separate browser context, which is exactly where + session cookies go missing. +3. **One design system.** `LButton`, dark mode, Inter, the zinc/slate palette all already + exist here. Matching them twice is a standing tax. + +The cost is that we render `ui.nodes` ourselves. That is bounded: known node groups +(`code`, `oidc`, `profile`) get the bespoke components in this branch, and anything +unrecognised falls through to a generic node renderer so a Kratos upgrade that adds a method +degrades to a plain form instead of a blank screen. + +What choosing this requires: + +- **Kratos public API must be same-site with the app.** Reverse-proxy it under the app's own + origin (`https://app.example.com/.ory/*` → Kratos public). This is Ory's own recommended + setup and it makes the session cookie and the CSRF cookie work without any `SameSite` + or CORS argument. +- **Submit the `csrf_token` node.** Every flow's `ui.nodes` carries it; build the POST body + from the nodes rather than hand-rolling fields, or the flow 403s. +- **Handle flow expiry.** Kratos flows expire (30 min by default, code flows sooner). That + is the `Flow expired` artboard — it restarts the flow rather than showing a raw error. +- **Allowlist `return_to`** in the Kratos config, so a guest gated on a page comes back to + that page rather than to the home screen. + +## Screens ↔ Kratos flows + +| Screen (artboard) | Flow | Notes | +| -------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | +| First run — sign in or look around | none | App's own decision, before any flow starts | +| Guest hits a gated action | none | Inline prompt, in place; not a modal over the content | +| Guest → account | `registration/browser` | Names what travels with the account | +| Choose a method | `login/browser` | `ui.nodes` groups `code` + `oidc` | +| Choose a method — email only | `login/browser` | Same screen with no `oidc` group configured | +| Identifier step | POST `ui.action`, `method=code` | | +| Identifier step — invalid address | — | `ui.nodes[identifier].messages` | +| Code step | POST `ui.action` | `code` node + `resend` | +| Code step — wrong code | — | Kratos message `4010008` | +| Code step — expired | — | Message `4060004`; resend is enabled | +| Code step — rate limited | — | HTTP 429; countdown holds the resend | +| Create an account | `registration/browser` | `traits.email`, `traits.name`, `method=code` | +| Create an account — already registered | — | Message `4000007`, steered to sign-in | +| Confirm your email | `verification/browser` | Same code component, different words | +| Verified | — | Session issued, redirect to `return_to` | +| Recover an account | `recovery/browser` | `method=code` | +| Recovery sent | — | Message `1060003` — deliberately vague, does not confirm the address exists | +| Account | `settings/browser` + `GET /sessions` | Sessions are an API read, not a flow | +| Flow expired | — | `410 Gone` | +| Offline | — | Never reaches Kratos; says what still works offline | +| Unhandled error | `self-service/errors?id=` | Shows the reference id, not the address | + +## Copy and translation + +`authCopy.ts` holds the English default for every string, keyed by the i18n key it will use +once the language docs carry it. `useAuthCopy()` prefers the translation and falls back to +that default, so the screens read correctly today and translate later without touching a +component. The table above doubles as the string inventory for whoever adds the keys. + +## Open questions + +- Does the CMS stay on the current OIDC providers while the app moves to Kratos, or is + Kratos meant to serve both? The screens assume app-only; the method list still shows OIDC + providers alongside email, so both can coexist. +- The privacy-policy gate (`useAuthWithPrivacyPolicy`) currently wraps every login start. + The designs carry a privacy note in the footer instead — decide whether the modal stays in + front of the flow or the note replaces it. +- Is a guest's bookmark set meant to sync per user once they have an account? The upgrade + screen promises "everything saved on this device comes with you", which is true today only + because nothing moves. It becomes a real promise the moment bookmarks sync. From 7c03d7dbf6d4dd724ec496a94f7500c48da7a2bb Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 20 Aug 2026 09:12:25 +0200 Subject: [PATCH 02/15] feat(app): wire the guest auth screens to Ory Kratos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-up and sign-in both run on the emailed one-time code, through the screens designed in the previous commit. One page serves login, signup, verification and recovery, because Kratos models them as the same two-step shape: address, then code. The payload is built from the flow's own ui.nodes rather than hand- written, which is what keeps csrf_token, the traits Kratos echoes back, and any field a later Kratos version adds in the body without a change here. Every submit outcome is a value, not a throw — a 400 carrying validation errors is an ordinary step in these flows. kratos/ holds a docker-compose Kratos and Mailpit for development. Kratos' serve.public.base_url is the app's own origin plus /.ory, which Vite proxies to the container, so every URL Kratos hands the browser is same-site and its cookies survive. Production wants the same shape from a real reverse proxy. Registered only when VITE_KRATOS_URL is set, so the routes exist nowhere they have not been switched on. The specs assert against flows recorded from Kratos v1.3.1 rather than against the documented shapes. What that recording settled: method=code sends the registration code directly without the two-step profile screen, login asks for identifier where registration asks for traits.email, and flat dotted trait keys are accepted as JSON. A Kratos session still does not authenticate anything to the Luminary API, which validates JWTs against a provider's JWKS. Closing that is a deliberate second step, written up in the doc. --- app/.env.example | 7 + app/src/auth/kratos/client.spec.ts | 71 +++++ app/src/auth/kratos/client.ts | 141 +++++++++ app/src/auth/kratos/fixtures.spec-data.json | 299 ++++++++++++++++++ app/src/auth/kratos/nodes.spec.ts | 60 ++++ app/src/auth/kratos/nodes.ts | 56 ++++ app/src/auth/kratos/types.ts | 56 ++++ app/src/auth/kratos/useKratosAuth.spec.ts | 137 ++++++++ app/src/auth/kratos/useKratosAuth.ts | 177 +++++++++++ .../auth/kratos/EmailIdentifierScreen.vue | 7 + app/src/pages/auth/KratosAuthPage.vue | 154 +++++++++ app/src/router/router.spec.ts | 13 +- app/src/router/routes.ts | 29 ++ app/vite.config.ts | 11 + docs/temp_kratos-guest-auth-ui.md | 64 ++++ kratos/README.md | 42 +++ kratos/config/identity.schema.json | 39 +++ kratos/config/kratos.yml | 113 +++++++ kratos/docker-compose.yml | 35 ++ 19 files changed, 1509 insertions(+), 2 deletions(-) create mode 100644 app/src/auth/kratos/client.spec.ts create mode 100644 app/src/auth/kratos/client.ts create mode 100644 app/src/auth/kratos/fixtures.spec-data.json create mode 100644 app/src/auth/kratos/nodes.spec.ts create mode 100644 app/src/auth/kratos/nodes.ts create mode 100644 app/src/auth/kratos/types.ts create mode 100644 app/src/auth/kratos/useKratosAuth.spec.ts create mode 100644 app/src/auth/kratos/useKratosAuth.ts create mode 100644 app/src/pages/auth/KratosAuthPage.vue create mode 100644 kratos/README.md create mode 100644 kratos/config/identity.schema.json create mode 100644 kratos/config/kratos.yml create mode 100644 kratos/docker-compose.yml diff --git a/app/.env.example b/app/.env.example index 2c72675bff..4af967dc43 100644 --- a/app/.env.example +++ b/app/.env.example @@ -38,3 +38,10 @@ VITE_SENTRY_DSN= # Optional — debugging aid only. Set to "true" to disable minification (readable # build output); any other value (or unset) minifies normally. VITE_BYPASS_MINIFY=false + +# Ory Kratos guest-auth proof of concept. Unset, the /auth/* routes are not +# registered and nothing changes. Set it to the same-origin proxy prefix that +# `kratos/config/kratos.yml` names in serve.public.base_url. +# VITE_KRATOS_URL="/.ory" +# Where the dev proxy forwards /.ory (defaults to the compose file's Kratos). +# KRATOS_PUBLIC_URL="http://127.0.0.1:4433" diff --git a/app/src/auth/kratos/client.spec.ts b/app/src/auth/kratos/client.spec.ts new file mode 100644 index 0000000000..3a0b84f241 --- /dev/null +++ b/app/src/auth/kratos/client.spec.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fixtures from "./fixtures.spec-data.json"; +import { submitFlow } from "./client"; +import type { KratosFlow } from "./types"; + +const flow = fixtures.registrationStart as KratosFlow; + +function respond(status: number, body: unknown, ok = status < 400) { + return { ok, status, json: async () => body } as Response; +} + +describe("submitFlow", () => { + beforeEach(() => vi.stubGlobal("fetch", vi.fn())); + afterEach(() => vi.unstubAllGlobals()); + + it("merges the caller's values over the ones Kratos sent", async () => { + vi.mocked(fetch).mockResolvedValue(respond(200, { session: { id: "s1" } })); + await submitFlow(flow, { method: "code", "traits.email": "a@b.com" }); + + const body = JSON.parse(vi.mocked(fetch).mock.calls[0][1]!.body as string); + expect(body.csrf_token).toBeTruthy(); + expect(body.method).toBe("code"); + expect(body["traits.email"]).toBe("a@b.com"); + }); + + it("treats a 400 as the next step, not a failure", async () => { + const next = fixtures.registrationAwaitingCode; + vi.mocked(fetch).mockResolvedValue(respond(400, next)); + + const result = await submitFlow(flow, {}); + expect(result.kind).toBe("flow"); + }); + + it("reports an expired flow rather than a generic error", async () => { + vi.mocked(fetch).mockResolvedValue(respond(410, {})); + expect((await submitFlow(flow, {})).kind).toBe("expired"); + }); + + it("follows a browser redirect Kratos asks for", async () => { + vi.mocked(fetch).mockResolvedValue( + respond(422, { redirect_browser_to: "https://idp.example/authorize" }), + ); + expect(await submitFlow(flow, {})).toEqual({ + kind: "redirect", + to: "https://idp.example/authorize", + }); + }); + + it("returns the session on success", async () => { + vi.mocked(fetch).mockResolvedValue(respond(200, { session: { id: "s1", active: true } })); + const result = await submitFlow(flow, {}); + expect(result).toMatchObject({ kind: "session", session: { id: "s1" } }); + }); + + it("sends the browser to verification when registration still owes one", async () => { + vi.mocked(fetch).mockResolvedValue( + respond(200, { + continue_with: [{ action: "show_verification_ui", flow: { id: "v1" } }], + }), + ); + expect(await submitFlow(flow, {})).toEqual({ + kind: "redirect", + to: "/auth/verify?flow=v1", + }); + }); + + it("calls a dropped connection offline instead of throwing", async () => { + vi.mocked(fetch).mockRejectedValue(new TypeError("Failed to fetch")); + expect((await submitFlow(flow, {})).kind).toBe("offline"); + }); +}); diff --git a/app/src/auth/kratos/client.ts b/app/src/auth/kratos/client.ts new file mode 100644 index 0000000000..092c1d8209 --- /dev/null +++ b/app/src/auth/kratos/client.ts @@ -0,0 +1,141 @@ +import type { FlowType, KratosFlow, KratosSession, SubmitResult } from "./types"; +import { collectDefaults } from "./nodes"; + +/** + * Same-origin by default: Vite proxies /.ory to the Kratos container in dev, and + * production is expected to reverse-proxy the same path. Kratos' own + * `serve.public.base_url` must agree, or the action URLs it hands back are + * cross-site and the browser drops the session cookie. + */ +export const KRATOS_BASE = import.meta.env.VITE_KRATOS_URL || "/.ory"; + +/** Whether the Kratos PoC is wired up at all. No env var, no routes. */ +export const isKratosEnabled = (): boolean => !!import.meta.env.VITE_KRATOS_URL; + +const jsonHeaders = { Accept: "application/json", "Content-Type": "application/json" }; + +/** Kratos answers 410/404 for a flow that has expired or was never issued. */ +const isGoneStatus = (status: number) => status === 410 || status === 404 || status === 403; + +async function readJson(response: Response): Promise { + return (await response.json()) as T; +} + +/** Start a browser flow. `returnTo` survives the round trip and decides where success lands. */ +export async function createFlow(type: FlowType, returnTo?: string): Promise { + const url = new URL(`${KRATOS_BASE}/self-service/${type}/browser`, window.location.origin); + if (returnTo) url.searchParams.set("return_to", returnTo); + + const response = await fetch(url, { + headers: { Accept: "application/json" }, + credentials: "include", + }); + if (!response.ok) throw new Error(`Could not start the ${type} flow (${response.status})`); + return readJson(response); +} + +/** Fetch the flow named in the URL Kratos redirected the browser to. */ +export async function fetchFlow(type: FlowType, id: string): Promise { + const url = new URL(`${KRATOS_BASE}/self-service/${type}/flows`, window.location.origin); + url.searchParams.set("id", id); + + const response = await fetch(url, { + headers: { Accept: "application/json" }, + credentials: "include", + }); + if (isGoneStatus(response.status)) return null; + if (!response.ok) throw new Error(`Could not read the ${type} flow (${response.status})`); + return readJson(response); +} + +/** + * Submit a flow, merging the caller's values over the ones Kratos sent. Every + * outcome is a value, not a throw: a 400 carrying validation errors is an + * ordinary step in these flows, not an exception. + */ +export async function submitFlow( + flow: KratosFlow, + values: Record, +): Promise { + let response: Response; + try { + response = await fetch(flow.ui.action, { + method: flow.ui.method || "POST", + headers: jsonHeaders, + credentials: "include", + body: JSON.stringify({ ...collectDefaults(flow), ...values }), + }); + } catch { + return { kind: "offline" }; + } + + if (isGoneStatus(response.status)) return { kind: "expired" }; + + // 422 is Kratos asking the browser to go somewhere else — an OIDC hand-off, + // or a flow that must continue at a different URL. + if (response.status === 422) { + const body = await readJson<{ redirect_browser_to?: string }>(response); + return body.redirect_browser_to + ? { kind: "redirect", to: body.redirect_browser_to } + : { kind: "error", message: "The sign-in could not continue." }; + } + + if (response.status === 400) { + return { kind: "flow", flow: await readJson(response) }; + } + + if (!response.ok) { + return { + kind: "error", + message: `Unexpected response from the sign-in service (${response.status})`, + }; + } + + const body = await readJson<{ + session?: KratosSession; + continue_with?: { action: string; flow?: { id: string } }[]; + redirect_browser_to?: string; + }>(response); + + if (body.session) return { kind: "session", session: body.session }; + if (body.redirect_browser_to) return { kind: "redirect", to: body.redirect_browser_to }; + // Registration that still owes a verification step reports it here. + const verification = body.continue_with?.find((step) => step.action === "show_verification_ui"); + if (verification?.flow?.id) { + return { kind: "redirect", to: `/auth/verify?flow=${verification.flow.id}` }; + } + return { kind: "error", message: "The sign-in could not be completed." }; +} + +/** The current session, or null when there isn't one. Never throws for "not signed in". */ +export async function whoami(): Promise { + try { + const response = await fetch( + new URL(`${KRATOS_BASE}/sessions/whoami`, window.location.origin), + { + headers: { Accept: "application/json" }, + credentials: "include", + }, + ); + if (!response.ok) return null; + return await readJson(response); + } catch { + return null; + } +} + +/** Kratos hands out a single-use logout URL rather than accepting a bare POST. */ +export async function logout(): Promise { + const response = await fetch( + new URL(`${KRATOS_BASE}/self-service/logout/browser`, window.location.origin), + { headers: { Accept: "application/json" }, credentials: "include" }, + ); + if (!response.ok) return false; + + const { logout_url } = await readJson<{ logout_url: string }>(response); + const done = await fetch(logout_url, { + headers: { Accept: "application/json" }, + credentials: "include", + }); + return done.ok; +} diff --git a/app/src/auth/kratos/fixtures.spec-data.json b/app/src/auth/kratos/fixtures.spec-data.json new file mode 100644 index 0000000000..504abfad8a --- /dev/null +++ b/app/src/auth/kratos/fixtures.spec-data.json @@ -0,0 +1,299 @@ +{ + "registrationStart": { + "id": "01277cde-55a7-4ccc-ab09-b2bc935e7805", + "ui": { + "action": "http://localhost:4174/.ory/self-service/registration?flow=01277cde-55a7-4ccc-ab09-b2bc935e7805", + "method": "POST", + "nodes": [ + { + "type": "input", + "group": "default", + "attributes": { + "name": "traits.email", + "type": "email", + "required": true, + "autocomplete": "email", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070002, + "text": "Email address", + "type": "info", + "context": { + "title": "Email address" + } + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "traits.name", + "type": "text", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070002, + "text": "Name", + "type": "info", + "context": { + "title": "Name" + } + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "value": "0xmd4u96tIMfuvA3i9rHZxKvB7h103EJ7wE+rBsDQKm9QHjcuVQbusQQ7aOS6mG1fXwp85Ad1n8eQPSty5Jhng==", + "required": true, + "disabled": false, + "node_type": "input" + } + }, + { + "type": "input", + "group": "profile", + "attributes": { + "name": "method", + "type": "submit", + "value": "profile", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1040001, + "text": "Sign up", + "type": "info" + } + } + } + ] + } + }, + "registrationAwaitingCode": { + "id": "01277cde-55a7-4ccc-ab09-b2bc935e7805", + "ui": { + "action": "http://localhost:4174/.ory/self-service/registration?flow=01277cde-55a7-4ccc-ab09-b2bc935e7805", + "method": "POST", + "nodes": [ + { + "type": "input", + "group": "default", + "attributes": { + "name": "traits.email", + "type": "hidden", + "value": "johan@example.com", + "required": true, + "autocomplete": "email", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070002, + "text": "Email address", + "type": "info", + "context": { + "title": "Email address" + } + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "traits.name", + "type": "hidden", + "value": "Johan", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070002, + "text": "Name", + "type": "info", + "context": { + "title": "Name" + } + } + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "value": "5zL2dfPlmNhZ078CAd9k1hnD9f9ocjU9bdPrYoFyfgKJaxNLpcs34YJ5opYY78IEdhDbtI28kkuckiFjUeNfNQ==", + "required": true, + "disabled": false, + "node_type": "input" + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "hidden", + "value": "code", + "disabled": false, + "node_type": "input" + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "code", + "type": "text", + "required": true, + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070012, + "text": "Registration code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070009, + "text": "Continue", + "type": "info" + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "resend", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070008, + "text": "Resend code", + "type": "info" + } + } + }, + { + "type": "input", + "group": "profile", + "attributes": { + "name": "screen", + "type": "submit", + "value": "credential-selection", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1040008, + "text": "Back", + "type": "info" + } + } + } + ], + "messages": [ + { + "id": 1040005, + "text": "An email containing a code has been sent to the email address you provided. If you have not received an email, check the spelling of the address and retry the registration.", + "type": "info" + } + ] + } + }, + "loginStart": { + "id": "4fedecbb-0ac2-4787-bee9-44a0c3df7363", + "ui": { + "action": "http://localhost:4174/.ory/self-service/login?flow=4fedecbb-0ac2-4787-bee9-44a0c3df7363", + "method": "POST", + "nodes": [ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "value": "GqbsuGziQrppSRfTkDI9uuan/kn/ms6n1nm2qFlV4+Ab9R+mL/FQ+LQIK2HQprRBlHzM4XnEMgwikcYZZn/Orw==", + "required": true, + "disabled": false, + "node_type": "input" + } + }, + { + "type": "input", + "group": "default", + "attributes": { + "name": "identifier", + "type": "text", + "value": "", + "required": true, + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1070002, + "text": "Email address", + "type": "info", + "context": { + "title": "Email address" + } + } + } + }, + { + "type": "input", + "group": "code", + "attributes": { + "name": "method", + "type": "submit", + "value": "code", + "disabled": false, + "node_type": "input" + }, + "meta": { + "label": { + "id": 1010015, + "text": "Send sign in code", + "type": "info" + } + } + } + ] + } + } +} diff --git a/app/src/auth/kratos/nodes.spec.ts b/app/src/auth/kratos/nodes.spec.ts new file mode 100644 index 0000000000..7aca7ca07c --- /dev/null +++ b/app/src/auth/kratos/nodes.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import fixtures from "./fixtures.spec-data.json"; +import { collectDefaults, firstMessage, isAwaitingCode, oidcProviders, traitValue } from "./nodes"; +import type { KratosFlow } from "./types"; + +// Recorded from Kratos v1.3.1 driving the real flows, so these assert against +// what Kratos actually sends rather than what it is documented to send. +const registrationStart = fixtures.registrationStart as KratosFlow; +const awaitingCode = fixtures.registrationAwaitingCode as KratosFlow; +const loginStart = fixtures.loginStart as KratosFlow; + +describe("kratos nodes", () => { + it("carries the csrf token into the submitted values", () => { + const values = collectDefaults(registrationStart); + expect(values.csrf_token).toBeTruthy(); + }); + + it("leaves submit buttons out of the payload", () => { + // `method` is a submit node on the start flow; sending it would pick a + // method the user never pressed. + expect(collectDefaults(registrationStart)).not.toHaveProperty("method"); + }); + + it("keeps the traits Kratos echoes back as hidden fields", () => { + const values = collectDefaults(awaitingCode); + expect(values["traits.email"]).toBe("johan@example.com"); + expect(values["traits.name"]).toBe("Johan"); + }); + + it("knows the code step from the flow itself", () => { + expect(isAwaitingCode(registrationStart)).toBe(false); + expect(isAwaitingCode(loginStart)).toBe(false); + expect(isAwaitingCode(awaitingCode)).toBe(true); + }); + + it("reads a trait value back for re-rendering the form", () => { + expect(traitValue(awaitingCode, "email")).toBe("johan@example.com"); + expect(traitValue(registrationStart, "email")).toBe(""); + }); + + it("surfaces the error even when an info message came first", () => { + const flow = { + ui: { + action: "", + method: "POST", + nodes: [], + messages: [ + { id: 1040005, text: "A code has been sent.", type: "info" as const }, + { id: 4010008, text: "The code is invalid.", type: "error" as const }, + ], + }, + id: "x", + }; + expect(firstMessage(flow)?.text).toBe("The code is invalid."); + }); + + it("finds no oidc providers when none are configured", () => { + expect(oidcProviders(loginStart)).toEqual([]); + }); +}); diff --git a/app/src/auth/kratos/nodes.ts b/app/src/auth/kratos/nodes.ts new file mode 100644 index 0000000000..86bd0ef422 --- /dev/null +++ b/app/src/auth/kratos/nodes.ts @@ -0,0 +1,56 @@ +import type { KratosFlow, KratosMessage, KratosNode } from "./types"; + +/** + * The values Kratos expects back, taken from the flow it just sent. Submitting + * from the nodes rather than a hand-written body is what keeps `csrf_token` — and + * any field a Kratos upgrade adds — in the payload without a code change here. + */ +export function collectDefaults(flow: KratosFlow): Record { + const values: Record = {}; + for (const node of flow.ui.nodes) { + const { name, value, type, disabled } = node.attributes; + if (!name || disabled) continue; + // Buttons carry the value that *would* be sent had the user pressed them. + if (type === "submit" || type === "button") continue; + if (value !== undefined && value !== null && value !== "") values[name] = value; + } + return values; +} + +export function findNode(flow: KratosFlow, name: string): KratosNode | undefined { + return flow.ui.nodes.find((node) => node.attributes.name === name); +} + +/** True once Kratos has sent the code and is waiting for it — the second step of the flow. */ +export function isAwaitingCode(flow: KratosFlow): boolean { + const code = findNode(flow, "code"); + return !!code && code.attributes.type !== "hidden"; +} + +/** Messages from the flow and from every node, newest-first is not a thing here — order is Kratos'. */ +export function allMessages(flow: KratosFlow): KratosMessage[] { + return [...(flow.ui.messages ?? []), ...flow.ui.nodes.flatMap((node) => node.messages ?? [])]; +} + +export function firstMessage(flow: KratosFlow): KratosMessage | undefined { + const messages = allMessages(flow); + // An error is what the user needs to see, even when an info message precedes it. + return messages.find((message) => message.type === "error") ?? messages[0]; +} + +/** OIDC providers Kratos offers on this flow, so social sign-in sits beside the email option. */ +export function oidcProviders(flow: KratosFlow): { id: string; label: string }[] { + return flow.ui.nodes + .filter((node) => node.group === "oidc" && node.attributes.name === "provider") + .map((node) => ({ + id: String(node.attributes.value ?? ""), + label: node.meta?.label?.text ?? String(node.attributes.value ?? ""), + })) + .filter((provider) => provider.id); +} + +/** The traits already entered, so a re-render after an error doesn't blank the form. */ +export function traitValue(flow: KratosFlow, trait: string): string { + const node = findNode(flow, `traits.${trait}`); + return node?.attributes.value ? String(node.attributes.value) : ""; +} diff --git a/app/src/auth/kratos/types.ts b/app/src/auth/kratos/types.ts new file mode 100644 index 0000000000..019399fb29 --- /dev/null +++ b/app/src/auth/kratos/types.ts @@ -0,0 +1,56 @@ +/** The slice of Kratos' flow model these screens read. Kratos sends more; nothing here needs it. */ +export type KratosMessage = { + id: number; + text: string; + type: "info" | "error" | "success"; +}; + +export type KratosNode = { + type: string; + group: string; + attributes: { + name?: string; + type?: string; + value?: unknown; + disabled?: boolean; + node_type?: string; + }; + messages?: KratosMessage[]; + meta?: { label?: { id: number; text: string } }; +}; + +export type KratosFlow = { + id: string; + expires_at?: string; + request_url?: string; + ui: { + action: string; + method: string; + nodes: KratosNode[]; + messages?: KratosMessage[]; + }; +}; + +export type KratosSession = { + id: string; + active?: boolean; + identity?: { + id: string; + traits: { email?: string; name?: string }; + verifiable_addresses?: { value: string; verified: boolean }[]; + }; +}; + +export type FlowType = "login" | "registration" | "verification" | "recovery" | "settings"; + +/** + * Every way a submit can land. `flow` is the ordinary "here are your validation + * errors" case — Kratos answers 400 with the same flow, re-rendered. + */ +export type SubmitResult = + | { kind: "session"; session: KratosSession } + | { kind: "flow"; flow: KratosFlow } + | { kind: "redirect"; to: string } + | { kind: "expired" } + | { kind: "offline" } + | { kind: "error"; message: string }; diff --git a/app/src/auth/kratos/useKratosAuth.spec.ts b/app/src/auth/kratos/useKratosAuth.spec.ts new file mode 100644 index 0000000000..9e76bd200f --- /dev/null +++ b/app/src/auth/kratos/useKratosAuth.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import fixtures from "./fixtures.spec-data.json"; +import { useKratosAuth } from "./useKratosAuth"; +import { createFlow, submitFlow } from "./client"; +import type { FlowType, KratosFlow, SubmitResult } from "./types"; + +vi.mock("./client", () => ({ + createFlow: vi.fn(), + fetchFlow: vi.fn(), + submitFlow: vi.fn(), +})); + +const registrationStart = fixtures.registrationStart as KratosFlow; +const awaitingCode = fixtures.registrationAwaitingCode as KratosFlow; +const loginStart = fixtures.loginStart as KratosFlow; + +/** onUnmounted needs a component instance, so the composable is hosted in one. */ +function host(type: FlowType) { + let auth!: ReturnType; + const wrapper = mount( + defineComponent({ + setup() { + auth = useKratosAuth(type); + return () => null; + }, + }), + ); + return { auth, wrapper }; +} + +const submitted = () => vi.mocked(submitFlow).mock.calls.at(-1)![1]; + +describe("useKratosAuth", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.mocked(createFlow).mockResolvedValue(registrationStart); + vi.mocked(submitFlow).mockResolvedValue({ kind: "flow", flow: awaitingCode }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("opens on the address step", async () => { + const { auth } = host("registration"); + await auth.start(); + expect(auth.step.value).toBe("identifier"); + }); + + it("submits registration as traits, because that is what the flow asks for", async () => { + const { auth } = host("registration"); + await auth.start(); + auth.email.value = "johan@example.com"; + auth.name.value = "Johan"; + await auth.submitIdentifier(); + + expect(submitted()).toMatchObject({ + method: "code", + "traits.email": "johan@example.com", + "traits.name": "Johan", + }); + }); + + it("submits login as an identifier, because that is what that flow asks for", async () => { + vi.mocked(createFlow).mockResolvedValue(loginStart); + const { auth } = host("login"); + await auth.start(); + auth.email.value = "johan@example.com"; + await auth.submitIdentifier(); + + expect(submitted()).toMatchObject({ method: "code", identifier: "johan@example.com" }); + expect(submitted()).not.toHaveProperty("traits.email"); + }); + + it("moves to the code step and holds the resend behind a countdown", async () => { + const { auth } = host("registration"); + await auth.start(); + await auth.submitIdentifier(); + + expect(auth.step.value).toBe("code"); + expect(auth.resendIn.value).toBe(30); + + await auth.resend(); + expect(vi.mocked(submitFlow)).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(30_000); + expect(auth.resendIn.value).toBe(0); + await auth.resend(); + expect(submitted()).toMatchObject({ resend: "code" }); + }); + + it("keeps the address on screen when Kratos re-renders the flow", async () => { + const { auth } = host("registration"); + await auth.start(); + auth.email.value = "johan@example.com"; + await auth.submitIdentifier(); + + // The awaiting-code flow echoes the traits back as hidden nodes. + expect(auth.email.value).toBe("johan@example.com"); + }); + + it("ends on the done step once a session comes back", async () => { + const { auth } = host("registration"); + await auth.start(); + vi.mocked(submitFlow).mockResolvedValue({ + kind: "session", + session: { id: "s1", active: true }, + } as SubmitResult); + await auth.submitIdentifier(); + + expect(auth.step.value).toBe("done"); + expect(auth.session.value?.id).toBe("s1"); + }); + + it("shows the expired screen rather than a generic failure", async () => { + const { auth } = host("registration"); + await auth.start(); + vi.mocked(submitFlow).mockResolvedValue({ kind: "expired" }); + await auth.submitCode(); + + expect(auth.step.value).toBe("expired"); + }); + + it("goes back to the address step without abandoning the flow", async () => { + const { auth } = host("registration"); + await auth.start(); + await auth.submitIdentifier(); + auth.code.value = "123456"; + + auth.changeIdentifier(); + expect(auth.step.value).toBe("identifier"); + expect(auth.code.value).toBe(""); + expect(auth.resendIn.value).toBe(0); + }); +}); diff --git a/app/src/auth/kratos/useKratosAuth.ts b/app/src/auth/kratos/useKratosAuth.ts new file mode 100644 index 0000000000..9506d145e7 --- /dev/null +++ b/app/src/auth/kratos/useKratosAuth.ts @@ -0,0 +1,177 @@ +import { computed, onUnmounted, ref, shallowRef } from "vue"; +import { createFlow, fetchFlow, submitFlow } from "./client"; +import { findNode, firstMessage, isAwaitingCode, oidcProviders, traitValue } from "./nodes"; +import type { FlowType, KratosFlow, KratosMessage, KratosSession, SubmitResult } from "./types"; + +export type AuthStep = + | "loading" + | "identifier" + | "code" + | "done" + | "expired" + | "offline" + | "failed"; + +const RESEND_COOLDOWN_SECONDS = 30; + +/** Kratos names the address field differently per flow; the flow itself says which. */ +function identifierField(flow: KratosFlow): string { + for (const candidate of ["identifier", "email", "traits.email"]) { + if (findNode(flow, candidate)) return candidate; + } + return "identifier"; +} + +/** + * Drives one Kratos self-service flow through the designed screens. Holds no + * opinion about which screen renders — the caller reads `step` and picks. + */ +export function useKratosAuth(type: FlowType) { + const flow = shallowRef(null); + const step = ref("loading"); + const busy = ref(false); + const email = ref(""); + const name = ref(""); + const code = ref(""); + const message = ref(null); + const failure = ref(""); + const session = ref(null); + const resendIn = ref(0); + + let countdown: ReturnType | undefined; + const stopCountdown = () => { + if (countdown) clearInterval(countdown); + countdown = undefined; + }; + function startCountdown() { + stopCountdown(); + resendIn.value = RESEND_COOLDOWN_SECONDS; + countdown = setInterval(() => { + resendIn.value -= 1; + if (resendIn.value <= 0) stopCountdown(); + }, 1000); + } + onUnmounted(stopCountdown); + + const providers = computed(() => (flow.value ? oidcProviders(flow.value) : [])); + + function adopt(next: KratosFlow) { + flow.value = next; + message.value = firstMessage(next) ?? null; + // Kratos re-sends what it already knows; keep the form filled after an error. + email.value = traitValue(next, "email") || email.value; + name.value = traitValue(next, "name") || name.value; + step.value = isAwaitingCode(next) ? "code" : "identifier"; + } + + /** Start the flow, or pick up the one Kratos named in `?flow=`. */ + async function start(flowId?: string, returnTo?: string) { + step.value = "loading"; + try { + const existing = flowId ? await fetchFlow(type, flowId) : null; + const next = existing ?? (await createFlow(type, returnTo)); + adopt(next); + if (isAwaitingCode(next)) startCountdown(); + } catch { + step.value = navigator.onLine === false ? "offline" : "failed"; + failure.value = "The sign-in service could not be reached."; + } + } + + async function send(values: Record): Promise { + if (!flow.value || busy.value) return null; + busy.value = true; + try { + const result = await submitFlow(flow.value, values); + switch (result.kind) { + case "session": + session.value = result.session; + step.value = "done"; + break; + case "flow": { + const wasAwaitingCode = step.value === "code"; + adopt(result.flow); + // Reaching the code step for the first time means a code was just sent. + if (step.value === "code" && !wasAwaitingCode) startCountdown(); + break; + } + case "redirect": + window.location.assign(result.to); + break; + case "expired": + step.value = "expired"; + break; + case "offline": + step.value = "offline"; + break; + case "error": + step.value = "failed"; + failure.value = result.message; + break; + } + return result; + } finally { + busy.value = false; + } + } + + /** Step one: hand Kratos the address and have it send a code. */ + async function submitIdentifier() { + if (!flow.value) return; + const field = identifierField(flow.value); + const values: Record = { method: "code", [field]: email.value }; + // Registration is the only flow that carries traits alongside the address. + if (type === "registration" && findNode(flow.value, "traits.name")) { + values["traits.name"] = name.value; + } + await send(values); + } + + /** Step two: the code itself. */ + async function submitCode() { + if (!flow.value) return; + const field = identifierField(flow.value); + await send({ method: "code", [field]: email.value, code: code.value }); + } + + async function resend() { + if (!flow.value || resendIn.value > 0) return; + const field = identifierField(flow.value); + code.value = ""; + const result = await send({ method: "code", [field]: email.value, resend: "code" }); + if (result?.kind === "flow") startCountdown(); + } + + /** Go back to the address step without abandoning the flow. */ + function changeIdentifier() { + code.value = ""; + message.value = null; + stopCountdown(); + resendIn.value = 0; + step.value = "identifier"; + } + + function chooseProvider(provider: string) { + return send({ method: "oidc", provider }); + } + + return { + step, + busy, + email, + name, + code, + message, + failure, + session, + resendIn, + providers, + start, + submitIdentifier, + submitCode, + resend, + changeIdentifier, + chooseProvider, + restart: () => start(), + }; +} diff --git a/app/src/components/auth/kratos/EmailIdentifierScreen.vue b/app/src/components/auth/kratos/EmailIdentifierScreen.vue index cb7355d90f..3f299f1d01 100644 --- a/app/src/components/auth/kratos/EmailIdentifierScreen.vue +++ b/app/src/components/auth/kratos/EmailIdentifierScreen.vue @@ -61,5 +61,12 @@ const submitLabel = () => {{ submitLabel() }} + + diff --git a/app/src/pages/auth/KratosAuthPage.vue b/app/src/pages/auth/KratosAuthPage.vue new file mode 100644 index 0000000000..71592f043e --- /dev/null +++ b/app/src/pages/auth/KratosAuthPage.vue @@ -0,0 +1,154 @@ + + + diff --git a/app/src/router/router.spec.ts b/app/src/router/router.spec.ts index b6f190b338..236d6fd93a 100644 --- a/app/src/router/router.spec.ts +++ b/app/src/router/router.spec.ts @@ -4,8 +4,17 @@ import { flushPromises } from "@vue/test-utils"; describe("Router", () => { describe("Router Configuration", () => { - it("should have the correct number of routes", () => { - expect(router.getRoutes()).toHaveLength(10); + it("should have the correct number of shipped routes", () => { + // Dev-only routes (the design canvas, the Kratos screens) are marked + // devOnly and ship in no build, so they don't count towards this. + const shipped = router.getRoutes().filter((route) => !route.meta.devOnly); + expect(shipped).toHaveLength(10); + }); + + it("should not register the Kratos routes without VITE_KRATOS_URL", () => { + expect(router.getRoutes().filter((route) => route.meta.devOnly)).not.toContainEqual( + expect.objectContaining({ path: "/auth/login" }), + ); }); it("should have home route configured correctly", () => { diff --git a/app/src/router/routes.ts b/app/src/router/routes.ts index 5c1575da1b..f065dc8701 100644 --- a/app/src/router/routes.ts +++ b/app/src/router/routes.ts @@ -11,6 +11,33 @@ const BookmarksPage = () => import("@/pages/BookmarksPage.vue"); const SingleContent = () => import("@/pages/SingleContent/SingleContent.vue"); const NotFoundPage = () => import("@/pages/NotFoundPage.vue"); const AuthDesignPage = () => import("@/pages/design/AuthDesignPage.vue"); +const KratosAuthPage = () => import("@/pages/auth/KratosAuthPage.vue"); + +/** + * Kratos self-service screens. Registered only when VITE_KRATOS_URL is set, so + * the proof of concept is inert everywhere it hasn't been switched on. The paths + * must match the `ui_url`s in the Kratos config. + */ +const kratosRoutes: RouteRecordRaw[] = import.meta.env.VITE_KRATOS_URL + ? ( + [ + ["/auth/login", "login"], + ["/auth/signup", "registration"], + ["/auth/verify", "verification"], + ["/auth/recovery", "recovery"], + ["/auth/account", "settings"], + ] as const + ).map(([path, flowType]) => ({ + path, + component: KratosAuthPage, + name: `kratos-${flowType}`, + props: { flowType }, + meta: { + analyticsIgnore: true, + devOnly: true, + }, + })) + : []; /** * Design canvas for the Kratos auth screens. Development only — it renders every @@ -24,6 +51,7 @@ const designRoutes: RouteRecordRaw[] = import.meta.env.DEV name: "design-auth", meta: { analyticsIgnore: true, + devOnly: true, }, }, ] @@ -96,6 +124,7 @@ export const routes: RouteRecordRaw[] = [ }, }, ...designRoutes, + ...kratosRoutes, // Note that this route should always come after all defined routes, // to prevent wrongly configured slugs from taking over pages { diff --git a/app/vite.config.ts b/app/vite.config.ts index 0532699592..0a86ed7b8a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -114,6 +114,17 @@ export default defineConfig({ watch: { ignored: ["dist/**", "dist-web/**", "**/dist/**", "**/dist-web/**"], }, + // Kratos has to answer on the app's own origin or the browser drops its + // session and CSRF cookies as cross-site. This proxy is the dev stand-in + // for the reverse proxy production needs; Kratos' `serve.public.base_url` + // names the same /.ory prefix so the URLs it hands back stay same-origin. + proxy: { + "/.ory": { + target: env.KRATOS_PUBLIC_URL || "http://127.0.0.1:4433", + changeOrigin: false, + rewrite: (path: string) => path.replace(/^\/\.ory/, ""), + }, + }, }, build: { target: "es2015", diff --git a/docs/temp_kratos-guest-auth-ui.md b/docs/temp_kratos-guest-auth-ui.md index 2f2d6e987f..6ab9ac5d3b 100644 --- a/docs/temp_kratos-guest-auth-ui.md +++ b/docs/temp_kratos-guest-auth-ui.md @@ -109,3 +109,67 @@ component. The table above doubles as the string inventory for whoever adds the - Is a guest's bookmark set meant to sync per user once they have an account? The upgrade screen promises "everything saved on this device comes with you", which is true today only because nothing moves. It becomes a real promise the moment bookmarks sync. + +## Proof of concept + +The screens are wired to a real Kratos. `kratos/README.md` has the run +instructions; the short version is `docker compose -f kratos/docker-compose.yml up -d` +plus `VITE_KRATOS_URL="/.ory"` in `app/.env`. Without that variable the `/auth/*` +routes are not registered at all, so the PoC is inert wherever it hasn't been +switched on. + +What is implemented: + +- `app/src/auth/kratos/client.ts` — the flow API over `fetch`, no SDK. Every + outcome is a value rather than a throw, because a 400 carrying validation + errors is an ordinary step in these flows. +- `app/src/auth/kratos/nodes.ts` — builds the submitted payload **from the flow's + own `ui.nodes`**. That is what keeps `csrf_token`, echoed traits, and any field + a Kratos upgrade adds in the body without a code change here. +- `app/src/auth/kratos/useKratosAuth.ts` — the state machine behind the screens: + address step → code step → session, plus resend cooldown, flow expiry and the + offline case. +- `app/src/pages/auth/KratosAuthPage.vue` — picks which designed screen renders + for the current step. One component serves login, signup, verification and + recovery, because Kratos models them as the same two-step shape. + +### What was verified against Kratos v1.3.1, not assumed + +- **Sign-up works in one method.** Posting `method: "code"` with traits to the + registration flow sends the code directly — the two-step `profile` screen newer + Kratos versions show by default is not on the path. Second post with the code + returns `200` and an active session, because registration runs the `session` hook. +- **Login is the same shape** with `identifier` instead of `traits.email`. The + flow itself says which field it wants, so `identifierField()` reads it off the + nodes rather than hard-coding a table. +- **Flat dotted keys are accepted as JSON.** `{"traits.email": "…"}` populates the + trait — so the payload built straight from node names needs no un-flattening. +- **The proxy topology holds.** Through `/.ory` the CSRF cookie comes back as + `Domain=localhost; SameSite=Lax` and `ui.action` points at the app's own origin. + +The recorded flows are checked in as `fixtures.spec-data.json` and the specs +assert against them, so the tests describe what Kratos actually sends. + +## The gap this PoC does not close + +**A Kratos session does not authenticate anything to the Luminary API.** Today +`AuthGuard` and `socketio.ts` validate a JWT against the JWKS of the provider named +in `x-auth-provider-id`. Kratos issues a cookie session, not a JWT. So a user who +completes this flow is signed in to Kratos and still anonymous to Luminary — which +is fine for the PoC (guests are anonymous by design) and blocks nothing that guests +can already do. + +Two ways to close it, when it matters: + +1. **Introspect in the API.** A new branch in `AuthIdentityService` that calls + Kratos' `/sessions/whoami` with the forwarded cookie, caches the answer briefly, + and maps the identity onto groups through `AutoGroupMappings` under a synthetic + provider id. No new infrastructure, no new crypto; costs one cacheable hop, and + the socket handshake needs the cookie rather than a bearer token. +2. **Put Ory Oathkeeper in front.** It converts the session into a JWT the existing + JWKS path already validates, so the API changes not at all — at the price of + another service to run and configure. + +For a self-hosted deployment I'd take (1): it keeps the moving parts in a codebase +we own, and the identity→groups mapping it needs is a thing `AuthIdentityService` +already does. Either way it is a deliberate second step, not an oversight in this one. diff --git a/kratos/README.md b/kratos/README.md new file mode 100644 index 0000000000..d4c905dcd5 --- /dev/null +++ b/kratos/README.md @@ -0,0 +1,42 @@ +# Kratos — guest auth proof of concept + +Development only. Nothing here is deployed, and nothing in `api/` depends on it. + +## Run it + +```sh +docker compose -f kratos/docker-compose.yml up -d +``` + +Then set `VITE_KRATOS_URL="/.ory"` in `app/.env` and start the app as usual +(`npm run dev` in `app/`). The routes appear only when that variable is set. + +- `/auth/login` — sign in with an emailed one-time code +- `/auth/signup` — create an account with the same code method +- `/auth/verify`, `/auth/recovery`, `/auth/account` +- **Codes arrive in Mailpit: http://localhost:8025** — no mail leaves the machine. + +## Why the /.ory proxy + +Kratos' `serve.public.base_url` is the _app's_ origin plus `/.ory`, not Kratos' +own address, and Vite proxies that prefix to the container. Every URL Kratos +hands the browser is therefore same-site with the app, so the session and CSRF +cookies survive. Production wants the same shape from a real reverse proxy — +that is the whole reason the screens live in the app rather than in a separate +self-service UI. + +## What is configured + +- `code.passwordless_enabled: true` — one setting turns the emailed code into a + login method _and_ a registration method. `password` and `link` are off. +- `identity.schema.json` — a `guest` identity: `email` (required, the code + identifier, verifiable) and an optional `name`. +- Registration runs the `session` hook, so signing up ends signed in. +- SQLite on a named volume, so identities survive `docker compose down`. Use + `down -v` to start from nothing. + +## Reset + +```sh +docker compose -f kratos/docker-compose.yml down -v +``` diff --git a/kratos/config/identity.schema.json b/kratos/config/identity.schema.json new file mode 100644 index 0000000000..31e26cf156 --- /dev/null +++ b/kratos/config/identity.schema.json @@ -0,0 +1,39 @@ +{ + "$id": "https://luminary.bcc.no/schemas/guest.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Luminary guest", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "Email address", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "code": { + "identifier": true, + "via": "email" + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "required": ["email"], + "additionalProperties": false + } + } +} diff --git a/kratos/config/kratos.yml b/kratos/config/kratos.yml new file mode 100644 index 0000000000..b53f768add --- /dev/null +++ b/kratos/config/kratos.yml @@ -0,0 +1,113 @@ +# Kratos configuration for the guest-auth proof of concept. Development only. +# +# `serve.public.base_url` is the app's own origin plus the proxy prefix, not +# Kratos' own address: every URL Kratos hands the browser (`ui.action`, redirects) +# has to be same-site with the app or the session and CSRF cookies are dropped. +# Vite proxies /.ory to this container — the same shape production should use. +version: v1.3.1 + +dsn: sqlite:///var/lib/sqlite/db.sqlite?_fk=true + +serve: + public: + base_url: http://localhost:4174/.ory/ + admin: + base_url: http://kratos:4434/ + +cookies: + domain: localhost + same_site: Lax + +session: + lifespan: 720h + cookie: + domain: localhost + same_site: Lax + +selfservice: + default_browser_return_url: http://localhost:4174/ + allowed_return_urls: + - http://localhost:4174 + + methods: + # One decision, both flows: `passwordless_enabled` turns the emailed + # one-time code into a login method as well as a registration method. + code: + enabled: true + passwordless_enabled: true + config: + lifespan: 15m + password: + enabled: false + link: + enabled: false + + flows: + error: + ui_url: http://localhost:4174/auth/error + + login: + ui_url: http://localhost:4174/auth/login + lifespan: 30m + + registration: + ui_url: http://localhost:4174/auth/signup + lifespan: 30m + after: + code: + hooks: + # Sign the user in as soon as they register, so the PoC + # ends on a session rather than on "now go and log in". + - hook: session + + verification: + enabled: true + ui_url: http://localhost:4174/auth/verify + use: code + after: + default_browser_return_url: http://localhost:4174/ + + recovery: + enabled: true + ui_url: http://localhost:4174/auth/recovery + use: code + + settings: + ui_url: http://localhost:4174/auth/account + privileged_session_max_age: 15m + + logout: + after: + default_browser_return_url: http://localhost:4174/ + +identity: + default_schema_id: guest + schemas: + - id: guest + url: file:///etc/config/kratos/identity.schema.json + +courier: + smtp: + # Mailpit, not a real relay — codes are read from its web UI on :8025. + connection_uri: smtp://mailpit:1025/?disable_starttls=true + from_address: no-reply@luminary.local + from_name: Luminary + +log: + level: debug + format: text + leak_sensitive_values: true + +secrets: + cookie: + - PLEASE-CHANGE-ME-I-AM-A-DEV-ONLY-SECRET + cipher: + - 32-LONG-SECRET-NOT-SECURE-AT-ALL + +ciphers: + algorithm: xchacha20-poly1305 + +hashers: + algorithm: bcrypt + bcrypt: + cost: 8 diff --git a/kratos/docker-compose.yml b/kratos/docker-compose.yml new file mode 100644 index 0000000000..cb87075b82 --- /dev/null +++ b/kratos/docker-compose.yml @@ -0,0 +1,35 @@ +# Local Kratos for the guest-auth PoC. Nothing here is deployed. +# docker compose -f kratos/docker-compose.yml up -d +# Kratos public API :4433 (reached through the app's /.ory proxy), admin :4434, +# Mailpit UI http://localhost:8025 — that is where the one-time codes arrive. +services: + kratos-migrate: + image: oryd/kratos:v1.3.1 + volumes: + - kratos-sqlite:/var/lib/sqlite + - ./config:/etc/config/kratos + command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes + restart: on-failure + + kratos: + image: oryd/kratos:v1.3.1 + depends_on: + kratos-migrate: + condition: service_completed_successfully + ports: + - "4433:4433" + - "4434:4434" + volumes: + - kratos-sqlite:/var/lib/sqlite + - ./config:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + restart: unless-stopped + + mailpit: + image: axllent/mailpit:latest + ports: + - "8025:8025" + restart: unless-stopped + +volumes: + kratos-sqlite: From 78d8d16c3ac59e1f3ff239ec24e4e1d90fe8873a Mon Sep 17 00:00:00 2001 From: Johan Bell Date: Thu, 20 Aug 2026 09:19:41 +0200 Subject: [PATCH 03/15] feat(app): an account screen for the Kratos proof of concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /auth/account was routed at the settings flow, which renders as an address prompt — the settings flow has none of the two-step shape the other screens are built around. It reads the session directly instead, so it is a page of its own rather than another flowType. Kratos leaves the current session out of GET /sessions by design; it comes from whoami, which carries the same devices and authenticated_at fields. The screen joins the two and puts this device first, or the "this device" row never appears. Identity deletion is not part of Kratos' self-service API, so the screen takes a canDelete prop and the PoC hides the offer rather than showing one that cannot work. The route-count spec now asserts that dev-only routes are flagged rather than counting the whole table, which depended on whether the PoC happened to be switched on in the environment running the tests. --- app/src/auth/kratos/client.ts | 35 +++++- app/src/auth/kratos/types.ts | 6 ++ .../auth/kratos/AccountSettingsScreen.vue | 4 + app/src/pages/auth/KratosAccountPage.vue | 101 ++++++++++++++++++ app/src/router/router.spec.ts | 15 ++- app/src/router/routes.ts | 15 ++- 6 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 app/src/pages/auth/KratosAccountPage.vue diff --git a/app/src/auth/kratos/client.ts b/app/src/auth/kratos/client.ts index 092c1d8209..c65b5a3aef 100644 --- a/app/src/auth/kratos/client.ts +++ b/app/src/auth/kratos/client.ts @@ -1,4 +1,10 @@ -import type { FlowType, KratosFlow, KratosSession, SubmitResult } from "./types"; +import type { + FlowType, + KratosFlow, + KratosSession, + KratosSessionListEntry, + SubmitResult, +} from "./types"; import { collectDefaults } from "./nodes"; /** @@ -108,7 +114,7 @@ export async function submitFlow( } /** The current session, or null when there isn't one. Never throws for "not signed in". */ -export async function whoami(): Promise { +export async function whoami(): Promise { try { const response = await fetch( new URL(`${KRATOS_BASE}/sessions/whoami`, window.location.origin), @@ -118,12 +124,35 @@ export async function whoami(): Promise { }, ); if (!response.ok) return null; - return await readJson(response); + return await readJson(response); } catch { return null; } } +/** + * The identity's *other* sessions. Kratos deliberately leaves the current one + * out — that comes from `whoami` — so the account screen joins the two. + */ +export async function listOtherSessions(): Promise { + const response = await fetch(new URL(`${KRATOS_BASE}/sessions`, window.location.origin), { + headers: { Accept: "application/json" }, + credentials: "include", + }); + if (!response.ok) return []; + return readJson(response); +} + +/** Ends every session except this one. Kratos models it as deleting the others. */ +export async function signOutOtherSessions(): Promise { + const response = await fetch(new URL(`${KRATOS_BASE}/sessions`, window.location.origin), { + method: "DELETE", + headers: { Accept: "application/json" }, + credentials: "include", + }); + return response.ok; +} + /** Kratos hands out a single-use logout URL rather than accepting a bare POST. */ export async function logout(): Promise { const response = await fetch( diff --git a/app/src/auth/kratos/types.ts b/app/src/auth/kratos/types.ts index 019399fb29..d79f4e0945 100644 --- a/app/src/auth/kratos/types.ts +++ b/app/src/auth/kratos/types.ts @@ -41,6 +41,12 @@ export type KratosSession = { }; }; +/** A row of `GET /sessions` — the same session, plus the device that opened it. */ +export type KratosSessionListEntry = KratosSession & { + authenticated_at?: string; + devices?: { id: string; ip_address?: string; user_agent?: string; location?: string }[]; +}; + export type FlowType = "login" | "registration" | "verification" | "recovery" | "settings"; /** diff --git a/app/src/components/auth/kratos/AccountSettingsScreen.vue b/app/src/components/auth/kratos/AccountSettingsScreen.vue index 44971f013d..29d83818ac 100644 --- a/app/src/components/auth/kratos/AccountSettingsScreen.vue +++ b/app/src/components/auth/kratos/AccountSettingsScreen.vue @@ -22,11 +22,14 @@ type Props = { emailVerified?: boolean; methods?: LinkedMethod[]; sessions?: ActiveSession[]; + /** Kratos has no self-service identity deletion, so hosts that lack it hide the offer. */ + canDelete?: boolean; }; withDefaults(defineProps(), { emailVerified: true, methods: () => [], sessions: () => [], + canDelete: true, }); defineEmits<{ changeEmail: []; @@ -153,6 +156,7 @@ const c = useAuthCopy();