mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user