mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -23,7 +23,7 @@ export type {
|
||||
MfaIdentity,
|
||||
MfaSetup,
|
||||
SignupRequest,
|
||||
ForgotRequest,
|
||||
CodeRequest,
|
||||
OAuthAuthorizeRequest,
|
||||
TokenResponse,
|
||||
AppLogin,
|
||||
|
||||
+40
-53
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user