read the client id the plain-link shape carries

hanzo.chat's homepage "Sign up" links to hanzo.id/signup/hanzo-chat -- the
client id in the PATH, from three live components (Visitor, MobileNav, the
error surface). App.tsx has always routed that shape (`path.startsWith
('/signup/')` and '/login/' are in its switch), so it is accepted by design.
The pages then read only `?client_id`, so the segment was matched and thrown
away.

What that produced: the page fell back to the host's default app, rendered a
form, and created the account under hanzo-console -- a DIFFERENT application
than the button that asked for it -- then left the new customer on hanzo.id
with no redirect_uri to return through. Signup "worked" and the funnel dropped
them.

One resolver, used by both pages. Query wins when both are present: it is the
OAuth request and the only one carrying a redirect_uri.

The segment must look like an IAM client id (<org>-<app>, the estate's one
naming rule) or it resolves to undefined and the host default applies, which is
exactly the previous behaviour. Tested: both shapes, precedence, and eight
rejects including a deeper path, wrong case, and a traversal attempt -- so
"accept anything" cannot pass. 175/175, typecheck clean.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-06 16:44:44 -07:00
co-authored by Hanzo Dev
parent f232b7aa67
commit da0856ef73
4 changed files with 73 additions and 2 deletions
+2 -1
View File
@@ -10,12 +10,13 @@ import {
type LoginResponse,
} from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import { clientIdFrom } from '../route'
export function Login({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const redirectUri = sp.get('redirect_uri') ?? undefined
const state = sp.get('state') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
const clientIdOverride = clientIdFrom(window.location.search, window.location.pathname)
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
+2 -1
View File
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react'
import type { BrandContract } from '@hanzo/id-shared'
import { SignupForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
import { BrandHeader } from '../components/BrandHeader'
import { clientIdFrom } from '../route'
export function Signup({ client, brand }: { client: AuthClient; brand: BrandContract }) {
const sp = new URLSearchParams(window.location.search)
const inviteCode = sp.get('invite') ?? undefined
const clientIdOverride = sp.get('client_id') ?? undefined
const clientIdOverride = clientIdFrom(window.location.search, window.location.pathname)
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
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'vitest'
import assert from 'node:assert/strict'
import { clientIdFrom } from './route'
test('the OAuth query shape wins — it is the one carrying a redirect_uri', () => {
assert.equal(clientIdFrom('?client_id=hanzo-chat', '/signup'), 'hanzo-chat')
// Both present: the query is the real request; the segment is decoration.
assert.equal(clientIdFrom('?client_id=hanzo-app', '/signup/hanzo-chat'), 'hanzo-app')
})
test('the plain-link path shape is read — this is the bug hanzo.chat hit', () => {
// Three live components in hanzoai/chat link exactly here. App.tsx routes
// `/signup/` and `/login/`, so the shape was accepted and then dropped: the
// page fell back to the host default and created the account under a
// DIFFERENT application than the button that asked for it.
assert.equal(clientIdFrom('', '/signup/hanzo-chat'), 'hanzo-chat')
assert.equal(clientIdFrom('', '/login/hanzo-chat'), 'hanzo-chat')
assert.equal(clientIdFrom('', '/signup/hanzo-cloud'), 'hanzo-cloud')
})
test('anything that is not one <org>-<app> segment falls back to the host default', () => {
// These must stay undefined, or the page would authenticate as whatever junk
// was in the URL — the fallback is the host's declared app, which is correct.
for (const p of [
'/signup', // bare page, the ordinary case
'/login',
'/signup/', // trailing slash, no segment
'/signup/nodash', // not <org>-<app>
'/signup/a/b', // deeper path
'/signup/Hanzo-Chat', // ids are lower-case
'/signup/../admin',
'/',
]) {
assert.equal(clientIdFrom('', p), undefined, p)
}
})
+33
View File
@@ -0,0 +1,33 @@
/**
* Which application a visitor arrived FOR.
*
* Two shapes reach these pages and both are deliberate:
*
* /signup?client_id=hanzo-chat the OAuth shape, carrying the whole
* request (redirect_uri, state, PKCE)
* /signup/hanzo-chat the plain-link shape, for a marketing
* "Sign up" that starts no OAuth request
*
* `App.tsx` has always routed the second one — `path.startsWith('/signup/')`
* and `'/login/'` are in its switch — so the shape is accepted by design. The
* PAGES then read only `?client_id`, so the segment was matched and thrown
* away: hanzo.chat links to `hanzo.id/signup/hanzo-chat` from three components,
* and every one of them landed on a page that had silently fallen back to the
* host's default app. An account created there belongs to a different
* application than the button that asked for it.
*
* Query wins when both are present: it is the OAuth request, and it is the one
* that carries a redirect_uri to return through.
*/
export function clientIdFrom(search: string, pathname: string): string | undefined {
const fromQuery = new URLSearchParams(search).get('client_id')
if (fromQuery) return fromQuery
// Exactly one segment after the page, and it must look like an IAM client id
// (`<org>-<app>`, the estate's one naming rule). Anything else — a deeper
// path, an encoded slash, junk — resolves to undefined and the caller falls
// back to the host default, which is the behaviour that was there before.
const seg = pathname.split('/').filter(Boolean)
if (seg.length !== 2) return undefined
return /^[a-z0-9]+(-[a-z0-9]+)+$/.test(seg[1]!) ? seg[1] : undefined
}