Skip to content

1927 research ory kratos setup for guest users - #1928

Draft
MrDirkelz wants to merge 15 commits into
mainfrom
1927-research-ory-kratos-setup-for-guest-users
Draft

1927 research ory kratos setup for guest users#1928
MrDirkelz wants to merge 15 commits into
mainfrom
1927-research-ory-kratos-setup-for-guest-users

Conversation

@MrDirkelz

Copy link
Copy Markdown
Collaborator

No description provided.

@MrDirkelz MrDirkelz linked an issue Aug 20, 2026 that may be closed by this pull request
@MrDirkelz
MrDirkelz force-pushed the 1927-research-ory-kratos-setup-for-guest-users branch 2 times, most recently from c8b400f to f9abde6 Compare August 25, 2026 14:12
johan-bell and others added 15 commits August 26, 2026 11:19
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.
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.
/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.
The screens existed but nothing led to them. The provider modal now
offers "Continue with email" beside the configured providers, which
routes to /auth/login carrying the current path as return_to rather than
going through loginWithProvider — Kratos is not an OIDC provider, and an
AuthProvider doc pointed at it would redirect to an /authorize endpoint
that does not exist.

The entry counts as a sign-in method, so the "no methods available"
state no longer shows when it is the only one.

A Kratos session is held separately from the OIDC one and surfaced as
its own profile-menu entry linking to /auth/account. Conflating them
would have the app claim an authority it does not have: the Luminary API
honours the OIDC token and knows nothing about a Kratos session.

All of it is behind VITE_KRATOS_URL, so an environment without the proof
of concept sees exactly what it saw before.

The modal's specs now drive that flag explicitly instead of inheriting
whatever the developer's own .env happens to say.
Kratos answers 400 session_already_available rather than opening a login
or registration flow while a session is live. createFlow threw on any
non-OK status, so clicking the guest entry with a session already in the
browser produced "Something went wrong — the sign-in service could not
be reached", which is neither what happened nor something the user can
act on.

Starting a flow now reports its three outcomes as values, and an
existing session sends the user to their account instead. A failure to
reach Kratos still reports as one, so the two stay distinguishable.

Also renames the entry to "Sign in as Guest", in the provider modal and
on the method chooser both.
Completing a guest sign-in left "Login" in the menu. Two causes, both
mine: the flow completes without a page load, so the shared session ref
was never re-read; and the profile menu and desktop sidebar render their
auth row from hard-coded markup rather than from the navigation list the
earlier commit changed, so only the dropdown ever saw a guest.

The auth row now has three states rather than two — signed in, guest, or
neither — and the account name falls back to the guest identity in both
surfaces. Signing out of a guest session is offered where signing out of
an OIDC session would be.

The label reads a possibly-plain `user` through unref: it is a ref in the
app and a bare object in several mounted tests, and the template's
unwrapping used to hide the difference.
Kratos cannot be an AuthProvider — no /authorize, no client_id, no JWKS,
no JWT. Hydra can. Kratos authenticates and Hydra issues the token, so a
guest is now an ordinary provider row: the app's oidc-client-ts redirect
and the API's JWKS validation both apply with no Kratos special case
anywhere, and the CMS manages it like any other provider.

Gone with it is the parallel guest session the Kratos-only PoC needed —
its shared ref, its entry in the provider modal, and the guest branches
in the profile menu and sidebar. The OIDC session is the session again;
a second one beside it was a second answer to who is signed in.

api/src/oauth/ handles Hydra's consent and logout hand-offs. Accepting
either needs Hydra's admin API, which a browser must never reach, so
urls.consent points at the API rather than the web client. It grants
only what was asked for and only for the one trusted client; any other
client is refused rather than quietly approved.

providerBaseUrl() honours an explicit scheme on a provider's domain and
still assumes https without one — the rule app/src/auth.ts already
applied on the client. The issuer and JWKS URL were hard-coded to https,
so no provider could be reached over http and no local Hydra could be
validated at all.

Two things the running stack settled, neither of them what the docs
imply. skip_consent on the client does not remove the consent round
trip: Hydra still redirects and still expects an accept. And a browser
flow must be navigated to rather than fetched — asking Kratos for one
with Accept: application/json returns 200 null in exactly the case where
it could have completed the flow itself.

Verified end to end against Hydra v2.2.0: iss, aud, client_id and sub
land where the validator expects them, tier arrives promoted out of ext,
and a refresh token is issued, which signinSilent() requires.
- Kratos handles identity/password auth; Hydra issues OAuth2 tokens
  consumed by Luminary's API, since the API validates bearer access
  tokens rather than Kratos sessions directly
- Adds a minimal login/consent bridge server to wire Kratos flows into
  Hydra's login/consent challenges, plus Caddy for local TLS routing
- Includes docker-compose.yml.bak as a reference of the pre-Hydra,
  Kratos-only setup this replaces
- Provides a browser-based dashboard (admin.luminary.local) to list/search
  identities and OAuth clients, revoke sessions across both Kratos and
  Hydra, and delete identities/clients during local PoC development.
- Wires the new `admin` service into docker-compose and Caddy alongside
  the existing login-consent flow.
The markup moves to login-consent/views.js as plain functions, so the
pages can be rendered — and looked at — without Kratos, Hydra or a
browser session.

Visually they follow the app: the wordmark from logo.svg with the text on
currentColor so it survives dark mode, the card treatment and type scale
from the app's own components, and the amber primary button. Light and
dark both, from prefers-color-scheme.

Three things were not cosmetic. Error text was interpolated into the page
unescaped, and it comes from Kratos' flow messages, which can carry what
a user typed. There was no viewport meta, so the pages rendered
desktop-width on a phone. And the 500 handler echoed err.message to the
browser — an exception from Kratos or Hydra says more about the
deployment than a user needs, so it goes to the log and the browser gets
a page instead.

Also: labels tied to their inputs, autocomplete hints, autofocus, and
aria-invalid with role="alert" when a submission is refused.
Drop the in-repo Kratos/Hydra integration (api OAuth controller, app auth
screens and routes, the ory/ compose stack, and the design notes) and keep
only ory-kratos-setup-poc, which stands on its own and does not touch the
application.

Also drop the superseded docker-compose.yml.bak left in the PoC directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkEPfBPbGbJUmYDLkunTWD
A second standalone Compose stack aimed at the same target as
ory-kratos-setup-poc: an OIDC provider the API accepts as an ordinary
AuthProvider doc, with no change to api/ or app/.

Zitadel bundles the OIDC provider, user management, login UI and admin
console in one service, so the stack is three long-running containers
instead of seven and needs none of the login/consent or admin code the
Ory stack requires.

verify-contract.mjs re-implements the checks authIdentity.service.ts
performs and reports each against the running stack, so the comparison
rests on observed behaviour rather than on documentation. Two constraints
are predicted to fail — the JWKS path, worked around in the Caddyfile,
and the issuer's trailing slash, which the README writes up without
changing the API.

The stack has not been run against a Docker daemon yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkEPfBPbGbJUmYDLkunTWD
@MrDirkelz
MrDirkelz force-pushed the 1927-research-ory-kratos-setup-for-guest-users branch from d129d4d to cacc724 Compare August 26, 2026 09:19
@MrDirkelz MrDirkelz self-assigned this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Research: Ory Kratos setup for Guest Users

3 participants