signup must end in a session — new customers were stranded on the portal

A customer who clicked "Create account" from an app never came back. Two
defects composed, and every unauthenticated probe read healthy through both.

`Login` linked to a bare `/signup`, so the OIDC request the app sent —
client_id, redirect_uri, state, PKCE challenge — was dropped at a full page
load. `Signup` already parsed exactly those params; nothing ever supplied
them.

Then IAM's signup is CREATE-ONLY. It persists the user and answers with the
row: no session cookie, no authorization code. The `autoSignin: true` the
client posted has no field in `internal/oidc/signup.go`, so the Go decoder
dropped it — "signed up" and "signed in" were never the same event. That
response fell through `parseLoginResponse`'s no-redirect arm to
`{redirectUrl: '/onboarding'}`, so the new user was sent to the portal's own
onboarding, unauthenticated, while the app waited on a code no one minted.

The links now carry the request, and `signup()` finishes the job through the
one path that establishes a session: it logs in, which mints the code bound
to this request's challenge and returns the redirect back to the app. A
signup that leaves you logged out is not a signup.

`parseCreated` reads the create leg, deliberately apart from
`parseLoginResponse` — that one INVENTS a destination when no redirect_uri
was asked for, which is right for a session and wrong for a row.

Registration also resolves the app via `get-app-login` the way `LoginForm`
does, so the account is created under the app the user came FROM rather than
the portal's own tenant.

Contracts locked in client.test.ts; both regressions verified by reverting the
fix and watching them fail. Driven end to end in Chromium against the built
SPA with IAM stubbed at its real response shapes: the params survive the
click and the browser lands on the app's callback holding the code.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-07-30 20:05:05 -07:00
co-authored by Hanzo Dev
parent 20db0cd39c
commit 66e96951a3
9 changed files with 216 additions and 17 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@hanzo/id-web",
"private": true,
"version": "0.1.29",
"description": "Hanzo ID — white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"version": "0.1.30",
"description": "Hanzo ID \u2014 white-label login / signup / IDV portal. Vite + React 19, styled from @hanzo/design tokens. Same image serves hanzo.id / lux.id / zoo.id / pars.id.",
"type": "module",
"scripts": {
"dev": "vite",
+6 -1
View File
@@ -199,7 +199,12 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
onMfaRequired={setMfa}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
{/* Carry the OIDC request across. These are full page loads, so a bare
href drops the client_id, redirect_uri, state and PKCE challenge the
app sent — and registration then has nothing to return the new user
to. `Signup` reads exactly these params. */}
<a href={`/forget${window.location.search}`}>Forgot password?</a> ·{' '}
<a href={`/signup${window.location.search}`}>Create account</a>
</p>
</main>
</div>
+18 -2
View File
@@ -7,6 +7,13 @@ export function Signup({ client, brand }: { client: AuthClient; brand: BrandCont
const inviteCode = sp.get('invite') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
const redirectUri = sp.get('redirect_uri') ?? undefined
// The same downstream OIDC request `Login` reads. Registration ends in a
// sign-in, so it needs the whole request — not just the client and its
// callback — or the minted code carries no PKCE binding and no state.
const state = sp.get('state') ?? undefined
const codeChallenge = sp.get('code_challenge') ?? undefined
const codeChallengeMethod = (sp.get('code_challenge_method') as 'S256' | 'plain' | null) ?? undefined
const nonce = sp.get('nonce') ?? undefined
return (
<div className="hanzo-id-page hanzo-id-signup">
<BrandHeader brand={brand} />
@@ -18,9 +25,18 @@ export function Signup({ client, brand }: { client: AuthClient; brand: BrandCont
intent="signup"
postLoginRedirect={redirectUri}
/>
<SignupForm client={client} inviteCode={inviteCode} />
<SignupForm
client={client}
inviteCode={inviteCode}
clientIdOverride={clientIdOverride}
redirectUri={redirectUri}
state={state}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
/>
<p className="hanzo-id-footer-links">
Already have an account? <a href="/login">Sign in</a>
Already have an account? <a href={`/login${window.location.search}`}>Sign in</a>
</p>
</main>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/id",
"private": true,
"version": "0.2.16",
"version": "0.2.17",
"description": "Hanzo ID \u2014 white-label login + identity verification portal (Vite + @hanzo/gui)",
"scripts": {
"build": "pnpm -r build",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/id-auth",
"version": "0.1.5",
"version": "0.1.6",
"description": "Composable login / signup / OTP / OAuth-PKCE flows on top of @hanzo/iam. UI primitives in @hanzo/gui.",
"license": "BSD-3-Clause",
"type": "module",
+76
View File
@@ -160,6 +160,82 @@ test('signup STILL sends organization (unchanged — create needs a concrete org
assert.equal(calls[0]!.body.organization, 'hanzo')
})
// REGRESSION (every new customer was stranded on the portal): IAM's signup is
// CREATE-ONLY — it sets no session and mints no code. Signup must therefore end
// in a real sign-in, or the app that sent the user waits forever for a code.
test('signup COMPLETES the OIDC request — create, then sign in, then redirect back with the code', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const res = await client.signup({
email: '[email protected]',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
redirectUri: 'https://hanzo.app/auth/callback',
state: 'xyz',
codeChallenge: 'CHALLENGE',
codeChallengeMethod: 'S256',
})
// Two legs, in order: create the row, then authenticate it.
assert.equal(calls.length, 2)
assert.match(calls[0]!.url, /\/v1\/iam\/signup/)
assert.match(calls[1]!.url, /\/v1\/iam\/login/)
// The sign-in leg carries the downstream request, so the code is PKCE-bound.
assert.match(calls[1]!.url, /code_challenge=CHALLENGE/)
assert.match(calls[1]!.url, /code_challenge_method=S256/)
assert.match(calls[1]!.url, /type=code/)
assert.equal(calls[1]!.body.username, '[email protected]')
// And the caller is handed a destination BACK AT THE APP — never the portal's
// own /onboarding, which is where the create-only response used to land.
assert.equal(res.redirectUrl, 'https://hanzo.app/auth/callback?code=AUTHCODE&state=xyz')
})
// `autoSignin` was posted for its name and dropped on the floor: the Go
// signupForm has no such field, so it never signed anyone in. Do not post a flag
// the server does not read — it is what made this look like it worked.
test('signup does NOT post autoSignin (IAM has no such field)', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ tenant: tenant(), fetchImpl })
await client.signup({
email: '[email protected]',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
})
assert.equal('autoSignin' in calls[0]!.body, false)
})
// IAM refuses with HTTP 200 + status:"error", so the status code proves nothing.
// A refused create must surface the reason and must NOT go on to try a login.
test('a refused signup surfaces the reason and never attempts a sign-in', async () => {
const seen: string[] = []
const fetchImpl: typeof fetch = async (input) => {
seen.push(typeof input === 'string' ? input : input.toString())
return new Response(JSON.stringify({ status: 'error', msg: 'email already exists', data: null }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
const client = createAuthClient({ tenant: tenant(), fetchImpl })
const res = await client.signup({
email: '[email protected]',
password: 'pw',
clientId: 'hanzo-app',
application: 'hanzo-app',
organization: 'hanzo',
redirectUri: 'https://hanzo.app/auth/callback',
})
assert.equal(res.error, 'email already exists')
assert.equal(res.redirectUrl, undefined)
assert.equal(seen.length, 1)
assert.match(seen[0]!, /\/v1\/iam\/signup/)
})
// REGRESSION (the `hanzo-iam does not exist` social-login bug): an IAM
// app-provider LINK can carry an outer `name` that is NOT the provider record's
// name (some seeds label it `<org>-iam`). The provider's real identity is the
+50 -2
View File
@@ -318,11 +318,37 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
email: req.email,
password: req.password,
confirm: req.password,
autoSignin: true,
...(req.inviteCode ? { invitationCode: req.inviteCode } : {}),
}),
})
return parseLoginResponse(res)
// Registration is CREATE-ONLY at IAM. `/v1/iam/signup` persists the user and
// answers with the created row — it sets no session cookie and mints no
// authorization code, and its form (`internal/oidc/signup.go`) has no
// `autoSignin`, `redirectUri` or `code_challenge` field to make it do so.
// The `autoSignin: true` this used to post was silently dropped by the Go
// decoder, so "signed up" and "signed in" were never the same event.
//
// Left there, the response fell through `parseLoginResponse`'s no-redirect
// arm to `{ redirectUrl: '/onboarding' }` — every new customer was sent to
// the portal's own onboarding, unauthenticated, while the app that sent them
// waited on a code that was never minted. So finish the job here: a signup
// that leaves you logged out is not a signup.
const created = await parseCreated(res)
if (created.error) return created
return login({
identifier: req.email,
password: req.password,
clientId: req.clientId,
application: req.application,
organization: req.organization,
redirectUri: req.redirectUri,
state: req.state,
codeChallenge: req.codeChallenge,
codeChallengeMethod: req.codeChallengeMethod,
nonce: req.nonce,
})
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
@@ -709,6 +735,28 @@ function parseAppLogin(
}
}
/**
* Read a create-only IAM response: `{status, msg, data}` where `data` is the
* created row. Success carries nothing the caller can navigate to, so this
* reports only whether it worked — never a redirect. Kept separate from
* `parseLoginResponse` precisely because that one INVENTS a destination when no
* `redirectUri` was requested, which is wrong for a row that is not a session.
*/
async function parseCreated(res: Response): Promise<{ error?: string }> {
let body: Record<string, unknown> = {}
try {
body = (await res.json()) as Record<string, unknown>
} catch {
return { error: `HTTP ${res.status} non-JSON response` }
}
// IAM answers a REFUSAL with HTTP 200 + status:"error" (see the org-less login
// note in this repo's LLM.md), so the status code alone proves nothing.
if (!res.ok || body.status === 'error') {
return { error: typeof body.msg === 'string' ? body.msg : `HTTP ${res.status}` }
}
return {}
}
async function parseLoginResponse(
res: Response,
req?: { redirectUri?: string; state?: string },
+16
View File
@@ -152,8 +152,24 @@ export interface SignupRequest {
readonly password: string
readonly clientId: string
readonly application: string
/**
* The tenant to create the user in. REQUIRED — unlike login's optional
* lookup hint, you cannot create a user in "no org", and IAM gates this
* against the application's own org.
*/
readonly organization: string
readonly inviteCode?: string
/**
* The downstream OIDC request, when an app sent the user here to register.
* Registration completes by signing the new user in, so these are forwarded
* to that sign-in: without them the minted code carries no PKCE binding and
* there is nowhere to return the user to.
*/
readonly redirectUri?: string
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
}
export interface ForgotRequest {
+46 -8
View File
@@ -4,7 +4,17 @@ import type { AuthClient } from '../client'
export interface SignupFormProps {
readonly client: AuthClient
readonly inviteCode?: string
readonly onSuccess?: () => void
/**
* The downstream OIDC request the user arrived with, when an app sent them
* here to register. Forwarded to the sign-in that follows account creation so
* the flow ends where it started — back at the app, holding a code.
*/
readonly redirectUri?: string
readonly state?: string
readonly clientIdOverride?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
}
export function SignupForm(props: SignupFormProps) {
@@ -19,17 +29,45 @@ export function SignupForm(props: SignupFormProps) {
setBusy(true)
setError(null)
try {
const res = await client.signup({
// Register against the app the user CAME FROM, not this portal. IAM's
// signup resolves the application by clientId and then gates the org
// against that app's own tenant, so a downstream `client_id` must reach
// it or the account is created under the portal's app instead.
const clientId = props.clientIdOverride ?? client.tenant.clientId
const app = await client.getAppLogin(clientId, props.redirectUri)
const application = app?.application ?? client.tenant.appName
const organization = app?.organization ?? client.tenant.orgId
const session = await client.signup({
email,
password,
clientId: client.tenant.clientId,
application: client.tenant.appName,
organization: client.tenant.orgId,
clientId,
application,
organization,
inviteCode: props.inviteCode,
redirectUri: props.redirectUri,
state: props.state,
codeChallenge: props.codeChallenge,
codeChallengeMethod: props.codeChallengeMethod,
nonce: props.nonce,
})
if (res.error) setError(res.error)
else if (res.redirectUrl) window.location.href = res.redirectUrl
else props.onSuccess?.()
if (session.error) {
setError(session.error)
return
}
if (session.redirectUrl) {
window.location.href = session.redirectUrl
return
}
// The account exists but the session did not complete here — an org that
// forces MFA answers the login with an enrollment step. Hand the user to
// the sign-in page, carrying the same OIDC request, rather than leaving
// them on a form that has nothing left to do.
if (session.mfaRequired) {
window.location.href = `/login${window.location.search}`
return
}
setError('Your account was created, but sign-in did not complete. Please sign in.')
} catch (err) {
setError(String(err))
} finally {