post what IAM reads, and show what IAM answered

Account recovery told the person "HTTP 400". Two independent defects, both in
one function:

  - the send POSTed JSON to an endpoint that reads its fields with fiber's
    FormValue, which parses urlencoded and multipart bodies and never a JSON
    one. Measured on production with this client's exact body: {"status":
    "error","msg":"missing parameter: type"}. The same request form-encoded
    gets past that check. So every field was unread and no code was ever
    minted — for recovery, and for any code sign-in built on the same send.

  - `if (!res.ok) return {error: "HTTP " + res.status}` ran BEFORE the body was
    parsed, throwing away the sentence IAM sends in `msg` alongside the 400.
    That sentence is the only thing a person can act on ("verification codes
    cannot be delivered: no notify service is configured").

forgot() becomes sendCode(): one send, one wire shape, for recovery AND for
code sign-in, because it is one endpoint minting one record and a second
method would only be a second way to spell it. The applicationId now comes
from the descriptor's own owner/name — `admin/` was hardcoded, which is a
guess that holds for the seeded estate and mints nothing for an application
owned by anyone else — and the fields IAM does not read (method, checkUser)
are gone from the wire.

The login body loses `signinMethod: 'Password'`, which matches no field on
IAM's loginForm, and carries `code` when it has one. Which credential is
present IS the choice of arm (login.go: "One credential is required, not
specifically a password: a code stands in its place"), so naming the arm was
noise that told the next person a code needs some other value there.

Recovery's copy now names what arrives. IAM mints a 6-digit code with a
ten-minute life and no link of any kind, so "check your inbox for a reset
link" described a mechanism that has never existed. The code is spendable at
exactly one place — the sign-in page's code arm, keyed on the same identifier
this page sent it to — so the page points there instead of promising a reset
form that no route serves; and with that arm switched off it refuses to mint
a code that could not be spent.

Also deletes AppLoginInfo / ProviderInfo / SigninMethod / CodeLoginRequest: a
parallel, unexported, unreferenced model of the same wire.
This commit is contained in:
2026-08-07 22:28:19 -07:00
parent df68b66214
commit 8ad7fcc4f0
6 changed files with 226 additions and 83 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ export function Forgot({ client, brand }: { client: AuthClient; brand: BrandCont
<div className="hanzo-id-page hanzo-id-forgot">
<BrandHeader brand={brand} />
<main>
<h1>Reset your {brand.name} password</h1>
{/* Not "reset your password": what this page can do is prove you hold the
address on the account and send a code that signs you back in. The new
password comes after, from inside the account. */}
<h1>Get back into your {brand.name} account</h1>
<ForgotForm client={client} />
<p className="hanzo-id-footer-links">
<a href="/login">Back to sign in</a>
+101
View File
@@ -815,3 +815,104 @@ test('signOut clears every hanzo_iam_* key, in BOTH storages, and returns the Id
g.localStorage = pl
}
})
// The OTP send is the one call in this client that is NOT JSON.
//
// IAM reads `dest`, `type` and `applicationId` with fiber's FormValue, which parses
// urlencoded and multipart bodies and never a JSON one — so posting JSON left every
// field unread and IAM answered "missing parameter: type". Measured on production
// against the exact body this client used to send.
test('sendCode posts form fields IAM can actually read', async () => {
const seen: { url: string; type: string | null; body: string }[] = []
const fetchImpl: typeof fetch = async (input, init) => {
seen.push({
url: input.toString(),
type: new Headers(init?.headers).get('Content-Type'),
body: String(init?.body ?? ''),
})
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 })
}
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.sendCode({
dest: '+14155550134',
channel: 'phone',
application: 'admin/hanzo-console',
})
assert.equal(res.ok, true)
assert.equal(seen.length, 1)
assert.equal(seen[0]!.type, 'application/x-www-form-urlencoded')
const fields = new URLSearchParams(seen[0]!.body)
assert.equal(fields.get('dest'), '+14155550134')
assert.equal(fields.get('type'), 'phone', 'the channel is IAM\'s `type` field')
assert.equal(fields.get('applicationId'), 'admin/hanzo-console')
// v1 accepted `method`/`checkUser`; iam reads neither, so they were noise on the
// wire that made the shape look like it needed them.
assert.equal(fields.has('method'), false)
assert.equal(fields.has('checkUser'), false)
})
// A refusal has to reach the person as the server's own sentence.
//
// The early `if (!res.ok) return {error: 'HTTP ' + res.status}` ran BEFORE the body
// was parsed, so IAM's explanation was read and thrown away: the recovery page
// rendered the literal string "HTTP 400" to someone trying to get back into their
// account.
test('sendCode surfaces the reason IAM gave, not its status code', async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
status: 'error',
msg: 'verification codes cannot be delivered: no notify service is configured',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
)
const client = createAuthClient({ org: org(), fetchImpl })
const res = await client.sendCode({
dest: '[email protected]',
channel: 'email',
application: 'admin/hanzo-console',
})
assert.equal(res.ok, false)
assert.equal(res.error, 'verification codes cannot be delivered: no notify service is configured')
})
// One credential per request, and which one IS the choice of arm: IAM reads a code
// where a password goes and never reaches the password check when one is present.
//
// `signinMethod: 'Password'` used to ride along on every login. IAM's loginForm has
// no such field, so the decoder dropped it — and leaving it there told the next
// person a code sign-in needs some other value in it, which is exactly the wrong
// thing to believe about an arm chosen by which credential is non-empty.
test('login sends the credential it was given and never names the arm', async () => {
const { calls, fetchImpl } = capturingFetch()
const client = createAuthClient({ org: org(), fetchImpl })
await client.login({
identifier: '+14155550134',
code: '123456',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[0]!.body.code, '123456')
assert.equal('password' in calls[0]!.body, false, 'a code login carries no password field')
assert.equal('signinMethod' in calls[0]!.body, false)
// The identifier goes on the wire exactly as typed — a phone number needs no
// client-side normalization, because IAM's own lookup normalizes it and a second
// normalizer is how two spellings of one number stop agreeing.
assert.equal(calls[0]!.body.username, '+14155550134')
await client.login({
identifier: '[email protected]',
password: 'pw',
clientId: 'hanzo-console',
application: 'hanzo-console',
organization: 'hanzo',
})
assert.equal(calls[1]!.body.password, 'pw')
assert.equal('code' in calls[1]!.body, false, 'a password login carries no code field')
})
+49 -21
View File
@@ -4,7 +4,7 @@ import type {
AppProvider,
DeviceApprovalResult,
DeviceInfoResult,
ForgotRequest,
CodeRequest,
LoginRequest,
LoginResponse,
MfaChallengeRequest,
@@ -31,7 +31,7 @@ export function mfaChannelOf(iamType: string): MfaChannel {
* Stateless wrapper around the canonical IAM REST surface (paths under
* `/v1/iam/*` and the OIDC paths under `/v1/iam/oauth/*`). One
* client instance per org. The portal creates one in `createRoot()`;
* downstream pages call `.login()`, `.signup()`, `.forgot()`, `.authorize()`
* downstream pages call `.login()`, `.signup()`, `.sendCode()`, `.authorize()`
* directly.
*
* Wire contract (verified against live IAM): the auth fields — `type`,
@@ -87,7 +87,19 @@ export interface AuthClient {
*/
deviceInfo(userCode: string): Promise<DeviceInfoResult>
signup(req: SignupRequest): Promise<LoginResponse>
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
/**
* Send a one-time code to the account's own email address or phone number.
*
* ONE send for both callers: recovery asks for it so a person who has lost
* their password can prove the address instead, and code sign-in asks for it so
* they can sign in with what arrives. It is one endpoint minting one record, so
* a second method would only be a second way to spell it.
*
* `ok:false` always carries IAM's own sentence — "verification codes cannot be
* delivered: no notify service is configured", "email is invalid" — because
* that sentence is the only thing the person can act on.
*/
sendCode(req: CodeRequest): Promise<{ ok: boolean; error?: string }>
authorize(req: OAuthAuthorizeRequest): string
exchange(code: string, codeVerifier?: string): Promise<TokenResponse>
logout(idTokenHint?: string, postLogoutRedirectUri?: string): string
@@ -183,13 +195,18 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
// resolves to the `admin` org (full multi-org session) instead of being
// pinned to — and truncated by — a colliding brand-org row. The session's
// org is always the resolved user's real owner, never this hint.
// ONE credential goes on the wire, and which one IS the choice of arm — IAM
// reads a code where a password goes and never reaches the password check when
// one is present. Nothing else names the arm: the `signinMethod: 'Password'`
// that used to sit here matches no field on IAM's loginForm, so the decoder
// dropped it, and leaving it told the next person a code needs a different
// value here. It does not.
const body: Record<string, unknown> = {
type,
username: req.identifier,
password: req.password,
application: req.application,
signinMethod: 'Password',
autoSignin: true,
...(req.code ? { code: req.code } : { password: req.password ?? '' }),
}
if (req.organization) body.organization = req.organization
const res = await f(url.toString(), {
@@ -409,25 +426,30 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
})
}
async function forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }> {
async function sendCode(req: CodeRequest): Promise<{ ok: boolean; error?: string }> {
const url = new URL('/v1/iam/send-verification-code', org.iamUrl)
url.searchParams.set('clientId', req.clientId)
url.searchParams.set('organization', req.organization)
// FORM-encoded, not JSON. This endpoint reads its fields with fiber's
// FormValue — a HIP-0111 §4 invariant, and the one call in this client that
// is not JSON. Posting JSON left every field unread, so IAM answered
// "missing parameter: type" and the recovery page rendered the person a bare
// "HTTP 400" for a request it never saw a field of.
const res = await f(url.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
applicationId: `admin/${org.appName}`,
organization: req.organization,
dest: req.identifier,
type: req.identifier.includes('@') ? 'email' : 'phone',
method: 'forget',
checkUser: req.identifier,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
dest: req.dest,
type: req.channel,
applicationId: req.application,
}).toString(),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
// Read the envelope BEFORE branching on the status code. IAM sends its reason
// in `msg` alongside a 400, so returning early on `!res.ok` threw away the
// only sentence worth showing.
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (body.status === 'error') return { ok: false, error: typeof body.msg === 'string' ? body.msg : 'failed' }
if (typeof body.msg === 'string' && (body.status === 'error' || !res.ok)) {
return { ok: false, error: body.msg }
}
if (!res.ok || body.status === 'error') return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
}
@@ -651,7 +673,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
approveDevice,
deviceInfo,
signup,
forgot,
sendCode,
authorize,
exchange,
logout,
@@ -742,8 +764,14 @@ function parseAppLogin(
}
})
.filter((p): p is AppProvider => p !== null)
const application = typeof data.name === 'string' ? data.name : fallbackApp
return {
application: typeof data.name === 'string' ? data.name : fallbackApp,
application,
// The row's own id. IAM's OTP send resolves the application by (owner, name),
// so the owner has to come from the descriptor: `admin/` was hardcoded here,
// which is a guess that happens to hold for the seeded estate and mints
// nothing for an application owned by anyone else.
id: `${typeof data.owner === 'string' && data.owner ? data.owner : 'admin'}/${application}`,
organization: typeof data.organization === 'string' ? data.organization : fallbackOrg,
enablePassword: data.enablePassword !== false,
enableSignUp: data.enableSignUp !== false,
+1 -1
View File
@@ -23,7 +23,7 @@ export type {
MfaIdentity,
MfaSetup,
SignupRequest,
ForgotRequest,
CodeRequest,
OAuthAuthorizeRequest,
TokenResponse,
AppLogin,
+40 -53
View File
@@ -1,6 +1,17 @@
export interface LoginRequest {
readonly identifier: string
readonly password: string
/**
* The credential. Exactly one of these two is sent, and IAM chooses the arm by
* which one is non-empty (`internal/oidc/login.go`: "One credential is
* required, not specifically a password: a code stands in its place").
*
* A code delivered to the account's own email address or phone number proves
* possession of that address, which is one factor exactly as a password is —
* so it enters the SAME endpoint and inherits the MFA gate, the device approval
* and the PKCE tail rather than getting a second login path.
*/
readonly password?: string
readonly code?: string
readonly clientId: string
readonly application: string
/**
@@ -198,10 +209,20 @@ export interface SignupRequest {
readonly nonce?: string
}
export interface ForgotRequest {
readonly identifier: string
readonly clientId: string
readonly organization: string
/**
* Ask IAM to send a one-time code — the ONE send, shared by account recovery and
* by code sign-in, because it is one endpoint minting one record.
*/
export interface CodeRequest {
/** Where the code goes: the account's own email address or phone number. */
readonly dest: string
/** Which of the two `dest` is. The screen knows; IAM validates. */
readonly channel: 'email' | 'phone'
/**
* The application the code is minted under, `owner/name` — {@link AppLogin.id},
* so it is read from the descriptor rather than assembled from a guessed owner.
*/
readonly application: string
}
export interface OAuthAuthorizeRequest {
@@ -217,47 +238,6 @@ export interface OAuthAuthorizeRequest {
readonly provider?: string
}
/** A third-party / wallet login provider attached to the application. */
export interface ProviderInfo {
readonly name: string
readonly displayName?: string
/** IAM provider type, e.g. GitHub, Google, Apple, Web3Onboard. */
readonly type?: string
/** IAM category, e.g. OAuth, Web3, SAML. */
readonly category?: string
readonly canSignIn?: boolean
readonly canSignUp?: boolean
}
/** A sign-in method offered by the application (Password, Verification code, WebAuthn, …). */
export interface SigninMethod {
readonly name: string
readonly rule?: string
}
/** The subset of the application's login config the portal renders from. */
export interface AppLoginInfo {
readonly name: string
readonly displayName?: string
readonly providers: ProviderInfo[]
readonly signinMethods: SigninMethod[]
readonly enablePassword: boolean
readonly enableCodeSignin: boolean
readonly enableSignUp: boolean
}
/** Passwordless login with an email/SMS verification code. */
export interface CodeLoginRequest {
/** Destination already sent a code: an email address or E.164 phone number. */
readonly dest: string
readonly code: string
readonly clientId: string
readonly application: string
readonly organization: string
readonly redirectUri?: string
readonly state?: string
}
export interface TokenResponse {
readonly accessToken: string
readonly refreshToken?: string
@@ -295,23 +275,30 @@ export interface AppProvider {
}
/**
* The enabled-auth-methods view of an IAM application, read live from
* `/v1/iam/get-app-login`. This is the canonical source of truth for which
* buttons to render — it reflects the per-app config in `init_data.json`
* (password + GitHub + Google + Web3). The portal renders exactly what IAM
* reports enabled, so there is no client/server method drift.
* What one IAM application offers a login screen, read live from
* `/v1/iam/get-app-login`. Every arm the screen draws is derived from a field
* here, so what is offered and what the server will complete cannot drift: IAM
* masks each switch with the capability behind it before answering (an
* `enableCodeSignin` it cannot deliver comes back false — `loginView`,
* internal/oidc/frontdoor.go).
*
* The one thing it does NOT describe is wallet sign-in, which is a capability of
* the IAM BINARY rather than of an application's config — see
* {@link offeredWalletChains}.
*/
export interface AppLogin {
/** IAM application name (e.g. `hanzo-id`). */
readonly application: string
/** The application's own id, `owner/name` — what an OTP is minted under. */
readonly id: string
/** Owning organization slug. */
readonly organization: string
/** Email/username + password sign-in is enabled. */
readonly enablePassword: boolean
/** Self-service signup is enabled. */
readonly enableSignUp: boolean
/** Email/SMS verification-code sign-in is enabled. */
/** Email/SMS code sign-in is enabled AND a code can be delivered. */
readonly enableCodeSignin: boolean
/** Social + Web3 providers enabled on the app, in display order. */
/** Social providers enabled on the app, in display order. */
readonly providers: readonly AppProvider[]
}
+31 -7
View File
@@ -15,14 +15,25 @@ export function ForgotForm(props: ForgotFormProps) {
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (busy) return
setBusy(true)
setError(null)
try {
const res = await client.forgot({
identifier,
clientId: client.org.clientId,
organization: client.org.orgId,
})
// The descriptor names the application the code is minted under (owner/name)
// and says whether a code can be spent here at all — both from the one read.
const app = await client.getAppLogin()
if (!app) {
setError('cannot read the sign-in configuration for this application')
return
}
if (!app.enableCodeSignin) {
// Refuse rather than mint. IAM sends a 6-digit code and nothing else, and
// the ONE place a code can be spent is the sign-in page's code arm; with
// that arm switched off the code would arrive with nowhere to go.
setError('this application cannot sign you in with a code yet, so a code cannot get you back in')
return
}
const res = await client.sendCode({ dest: identifier, channel: 'email', application: app.id })
if (!res.ok) setError(res.error ?? 'send failed')
else {
setSent(true)
@@ -36,7 +47,20 @@ export function ForgotForm(props: ForgotFormProps) {
}
if (sent) {
return <p className="hanzo-id-info">Check your inbox for a reset link.</p>
// Name what actually arrives. IAM mints a 6-digit code with a ten-minute life
// and no link of any kind (verificationCodeLength, sendverificationcode.go), so
// "check your inbox for a reset link" described a mechanism that has never
// existed and left the person watching for a mail nobody was going to send.
//
// The code is spendable at exactly one place — the sign-in page's code arm,
// keyed on the same identifier this page sent it to — which is why recovery
// needs no page of its own: proving the address IS the way back in.
return (
<p className="hanzo-id-info">
We sent a 6-digit code to {identifier}. <a href="/login">Sign in with the code</a> to get
back in, then set a new password.
</p>
)
}
return (
@@ -46,7 +70,7 @@ export function ForgotForm(props: ForgotFormProps) {
<input className="hanzo-id-input" type="email" autoComplete="email" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
</label>
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send reset link'}</button>
<button type="submit" className="hanzo-id-btn" disabled={busy}>{busy ? 'Sending…' : 'Send code'}</button>
</form>
)
}