device approval: name the client from the code, not the portal
The page said "You are about to authorize hanzo-console" for a sign-in started
by hanzo-cli. appLabel was org.appName — this PORTAL's own branding, a static
per-brand string — so every device code, whichever client minted it, was
approved under the same wrong name. The one job of the screen is to say WHICH
application you are authorizing, and a screen that names the wrong one does not
merely fail to help: it teaches people the name means nothing.
bd04657 removed the false name but concluded the right one could not be learned
without building a code-hunting oracle. That was true of an open lookup and is
not true of the one IAM now serves: POST /v1/iam/oauth/device/info is session
gated, org scoped, and answers unknown / expired / already-approved with a
single opaque refusal, so it reveals strictly less than the approval the same
caller could already attempt. The comment arguing the endpoint must not exist is
replaced by what is actually true of it.
The code rides the POST BODY. A user_code is the one secret in this flow and a
request line is copied into ingress and proxy access logs where a body is not —
this page already ships scrubUrl() to keep the code out of the address bar, and
a GET would have undone that on the server side.
Approve is gated on a resolved name and fails closed: no server-confirmed
application, no button. Nothing local is ever substituted, because rendering a
guess is the defect being fixed. THIS MAKES THE PORTAL DEPEND ON IAM b466bd63 —
ship IAM first or together, or every approval blocks.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import type { BrandContract } from '@hanzo/id-shared'
|
||||
import { LoginForm, SocialButtons, type AuthClient } from '@hanzo/id-auth'
|
||||
import { LoginForm, SocialButtons, type AuthClient, type DeviceInfoResult } from '@hanzo/id-auth'
|
||||
import { BrandHeader } from '../components/BrandHeader'
|
||||
|
||||
/**
|
||||
@@ -18,6 +18,11 @@ import { BrandHeader } from '../components/BrandHeader'
|
||||
* page reads it back from `/v1/iam/get-account` and shows the confirm step.
|
||||
* Approval rides that session cookie (`client.approveDevice`), so no token ever
|
||||
* touches the URL or logs.
|
||||
*
|
||||
* The screen exists to answer ONE question — which application am I authorizing?
|
||||
* — so the application it names is read from the code (`client.deviceInfo`) and
|
||||
* from nowhere else. Until IAM has named one there is no name on screen and no
|
||||
* button to press.
|
||||
*/
|
||||
|
||||
type Phase =
|
||||
@@ -64,23 +69,24 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
// victim being walked through a crafted link ticks it as readily as they
|
||||
// click Approve. It bought nothing and cost every honest user a step.
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// NOT the client being approved. org.appName is this PORTAL's branding — a
|
||||
// static per-org string — while the device authorization names its own
|
||||
// application, which lives on the pending row and is what the backend actually
|
||||
// approves (internal/oidc/device.go approveDevice: "the portal app the browser
|
||||
// happens to be on is irrelevant to WHAT is being approved").
|
||||
// WHICH application is asking — the whole point of this screen, and the one
|
||||
// thing the page cannot know on its own. It used to render `org.appName`: this
|
||||
// PORTAL's own branding, a static per-org string, so a sign-in started by
|
||||
// hanzo-cli was approved under a screen reading "hanzo-console". The client is
|
||||
// a property of the CODE (it lives on the pending row and is what the backend
|
||||
// actually approves), so it is read from the code — `client.deviceInfo`,
|
||||
// IAM `POST /v1/iam/oauth/device/info`.
|
||||
//
|
||||
// So the page cannot honestly name the requesting client, and it must not
|
||||
// LEARN it either: the user_code is 40 bits and the one secret in this flow, so
|
||||
// an endpoint that said which app a code belongs to would be an oracle for
|
||||
// hunting live codes — exactly what device.go refuses to be, with one opaque
|
||||
// refusal for unknown / expired / already-approved.
|
||||
// That read is session-gated and answers with one opaque refusal for unknown /
|
||||
// expired / already-approved, so it is no oracle for hunting live codes: it
|
||||
// tells a caller strictly less than the approval that same caller could already
|
||||
// attempt.
|
||||
//
|
||||
// It read "You are about to authorize hanzo-console" for a sign-in started by
|
||||
// hanzo-cli. A consent screen that names the wrong party is worse than one that
|
||||
// names none: it teaches people that the name means nothing. What this page CAN
|
||||
// vouch for is the code the human transcribed, so that is what it asks about.
|
||||
const portalLabel = client.org.appName
|
||||
// null = not resolved yet. NOTHING is rendered in its place — no fallback name,
|
||||
// no portal name, no guess. Naming the wrong party is the defect being fixed
|
||||
// here, and a screen that names none is strictly better than one that lies.
|
||||
const [app, setApp] = useState<DeviceInfoResult | null>(null)
|
||||
const named = app?.ok ? app : null
|
||||
|
||||
// Resolve the issuer session: signed in → confirm, else → sign-in form. Reads
|
||||
// same-origin from `/v1/iam/get-account` (cookie session; the brand `*.id`
|
||||
@@ -110,6 +116,35 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
}
|
||||
}, [client.org.iamUrl])
|
||||
|
||||
// Ask WHICH application the code belongs to. Needs both halves of what the
|
||||
// endpoint is gated on: the issuer session (every phase past the check has one
|
||||
// except `signin`, and the boolean keeps consent/approving from re-asking) and
|
||||
// a code to ask about.
|
||||
//
|
||||
// The debounce is what makes a hand-typed code work: each keystroke is a
|
||||
// different code, and a partial one is not a real code — without it the human
|
||||
// watches IAM's refusal flash at them while they are still typing.
|
||||
const signedIn = phase.s !== 'checking' && phase.s !== 'signin'
|
||||
const blank = userCode.trim().length === 0
|
||||
useEffect(() => {
|
||||
if (!signedIn || blank) return
|
||||
let alive = true
|
||||
const t = setTimeout(() => {
|
||||
client.deviceInfo(userCode).then((r) => {
|
||||
if (!alive) return
|
||||
// The session lapsed between the get-account check and this read. The
|
||||
// signin phase preserves the code in `returnTo`, so the human lands back
|
||||
// here with it intact.
|
||||
if (!r.ok && r.loginRequired) setPhase({ s: 'signin' })
|
||||
else setApp(r)
|
||||
})
|
||||
}, 250)
|
||||
return () => {
|
||||
alive = false
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [client, userCode, signedIn, blank])
|
||||
|
||||
async function approve() {
|
||||
setError(null)
|
||||
setPhase({ s: 'approving' })
|
||||
@@ -163,15 +198,30 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
const busy = phase.s === 'approving'
|
||||
const consent = phase.s === 'consent'
|
||||
const email = phase.s === 'confirm' || phase.s === 'consent' ? phase.email : undefined
|
||||
// ONE place shows a failure, whichever leg produced it: the approval itself, or
|
||||
// the lookup that has to name an application before an approval is offered.
|
||||
const failure = error ?? (app && !app.ok ? app.error : null)
|
||||
|
||||
return (
|
||||
<Shell brand={brand}>
|
||||
<h1>Approve this device</h1>
|
||||
{email ? <p className="lede">Signed in as {email}</p> : null}
|
||||
|
||||
{/* The application is named ONLY once IAM has confirmed it — the clientId
|
||||
alongside the display name, so a technical human can check it reads
|
||||
`hanzo-cli` exactly and not something that merely looks like it. Until
|
||||
then the sentence says a device, because that is all the page knows. */}
|
||||
<p className="hanzo-id-device-prompt">
|
||||
A device is asking to sign in as you. Approve ONLY if the code below matches the
|
||||
one shown on that device, and only if you started this sign-in yourself.
|
||||
{named ? (
|
||||
<>
|
||||
<strong>{named.displayName}</strong> (<code>{named.clientId}</code>) is asking to
|
||||
sign in as you.
|
||||
</>
|
||||
) : (
|
||||
'A device is asking to sign in as you.'
|
||||
)}{' '}
|
||||
Approve ONLY if the code below matches the one shown on that device, and only if you
|
||||
started this sign-in yourself.
|
||||
</p>
|
||||
|
||||
<label className="hanzo-id-field">
|
||||
@@ -186,26 +236,36 @@ export function DeviceApproval({ client, brand }: { client: AuthClient; brand: B
|
||||
aria-label="Device code"
|
||||
className="hanzo-id-input hanzo-id-device-code"
|
||||
value={userCode}
|
||||
onChange={(e) => setUserCode(e.target.value)}
|
||||
// A name — and a failure — belongs to a CODE. Edit the code and both are
|
||||
// dropped in the same commit, so no name is ever left on screen for a
|
||||
// frame beside a code it was not confirmed for.
|
||||
onChange={(e) => {
|
||||
setUserCode(e.target.value)
|
||||
setApp(null)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="e.g. K7M4P2QH"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{consent ? (
|
||||
{consent && named ? (
|
||||
<p className="hanzo-id-info">
|
||||
{portalLabel} needs your consent to continue. By approving you grant the device
|
||||
showing this code access to your profile.
|
||||
<strong>{named.displayName}</strong> needs your consent to continue. By approving
|
||||
you grant the device showing this code access to your profile.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p role="alert" className="hanzo-id-error">{error}</p> : null}
|
||||
{failure ? <p role="alert" className="hanzo-id-error">{failure}</p> : null}
|
||||
|
||||
<div className="hanzo-id-cta-row">
|
||||
<button
|
||||
type="button"
|
||||
// Nothing is approved until IAM has named what is being approved. An
|
||||
// unresolved or refused lookup leaves no button to press, rather than a
|
||||
// button that authorizes an unnamed party.
|
||||
className="hanzo-id-btn"
|
||||
disabled={busy || userCode.trim().length === 0}
|
||||
disabled={busy || !named}
|
||||
onClick={approve}
|
||||
>
|
||||
{busy ? 'Approving…' : consent ? 'Approve & grant access' : 'Approve'}
|
||||
|
||||
@@ -483,6 +483,180 @@ test('approveDevice maps the consent-required branch to { required: true }', asy
|
||||
assert.equal(r.error, undefined)
|
||||
})
|
||||
|
||||
// ── Which application is this code for? (deviceInfo) ─────────────────────────
|
||||
// A one-call double for `POST /v1/iam/oauth/device/info`: records what the
|
||||
// request actually was (URL, method, credentials, body) and answers with
|
||||
// `payload`.
|
||||
function deviceInfoFetch(payload: unknown) {
|
||||
const calls: {
|
||||
url: string
|
||||
method?: string
|
||||
credentials?: RequestCredentials
|
||||
body?: string
|
||||
}[] = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
calls.push({
|
||||
url: typeof input === 'string' ? input : input.toString(),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
})
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
// THE REGRESSION THIS FILE EXISTS FOR. The approval page used to render
|
||||
// `org.appName` — the PORTAL's own branding, the static `hanzo-console` this
|
||||
// test's org() is configured with — so a device sign-in started by `hanzo-cli`
|
||||
// was approved under a screen naming a different application. The name must come
|
||||
// off the RESPONSE, which is the code's own application, and never off the org
|
||||
// config; asserting both is what keeps the two from being confused again.
|
||||
test('deviceInfo names the RESPONSE client, never the portal org appName', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const cfg = org()
|
||||
const client = createAuthClient({ org: cfg, fetchImpl })
|
||||
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.clientId, 'hanzo-cli')
|
||||
assert.equal(r.ok && r.displayName, 'Hanzo CLI')
|
||||
// The portal is hanzo-console. Nothing about it may reach the result.
|
||||
assert.equal(cfg.appName, 'hanzo-console')
|
||||
assert.notEqual(r.ok && r.clientId, cfg.appName)
|
||||
assert.notEqual(r.ok && r.displayName, cfg.appName)
|
||||
|
||||
// A session-cookie POST at the /v1/ device-info path. The user_code is the one
|
||||
// secret in this flow, so it rides the BODY: a request line is copied into
|
||||
// ingress and proxy access logs where a body is not, and this page ships
|
||||
// scrubUrl() precisely to keep the code out of URLs.
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.method, 'POST')
|
||||
assert.equal(calls[0]!.credentials, 'include')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
assert.equal(calls[0]!.url.includes('K7M4P2QH'), false)
|
||||
})
|
||||
|
||||
// Same normalization as the approval: a code transcribed lower-cased or with
|
||||
// dashes must resolve to the same row IAM minted, or the page would refuse to
|
||||
// name an application that is perfectly live.
|
||||
test('deviceInfo uppercases and strips spaces/dashes into the body', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({
|
||||
status: 'ok',
|
||||
data: { clientId: 'hanzo-cli', displayName: 'Hanzo CLI' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
await client.deviceInfo(' k7m4-p2qh ')
|
||||
assert.equal(calls[0]!.url, 'https://hanzo.id/v1/iam/oauth/device/info')
|
||||
assert.equal(calls[0]!.body, JSON.stringify({ userCode: 'K7M4P2QH' }))
|
||||
})
|
||||
|
||||
// An empty code names nothing and never hits the network.
|
||||
test('deviceInfo rejects an empty code without calling fetch', async () => {
|
||||
const { calls, fetchImpl } = deviceInfoFetch({ status: 'ok', data: {} })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo(' ')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// IAM `CodeLoginRequired`: the session lapsed. Flagged separately from a refusal
|
||||
// because the page's answer is to sign the human in and come back, not to give up.
|
||||
test('deviceInfo flags login_required distinctly from a refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'please sign in first',
|
||||
code: 'login_required',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.loginRequired, true)
|
||||
assert.equal(r.ok === false && r.error, 'please sign in first')
|
||||
})
|
||||
|
||||
// The ONE opaque refusal IAM answers for unknown / expired / already-approved —
|
||||
// surfaced verbatim, carrying no loginRequired, so the page shows it and offers
|
||||
// no approval. Distinguishing those three would be an oracle for hunting the
|
||||
// 40-bit user_code; the client must not invent a distinction either.
|
||||
test('deviceInfo surfaces the opaque refusal verbatim and does not name an app', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'the user code is invalid or expired',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'the user code is invalid or expired')
|
||||
assert.equal(r.ok === false && r.loginRequired, undefined)
|
||||
})
|
||||
|
||||
// The org-boundary refusal is a plain refusal too: surfaced, not special-cased.
|
||||
test('deviceInfo surfaces the wrong-org refusal', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({
|
||||
status: 'error',
|
||||
msg: 'your organization may not approve this device sign-in',
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.equal(r.ok === false && r.error, 'your organization may not approve this device sign-in')
|
||||
})
|
||||
|
||||
// An HTML error page from a proxy is not an application name. It must fail,
|
||||
// never resolve to a blank or guessed one.
|
||||
test('deviceInfo fails on a non-JSON response', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response('<html>502 Bad Gateway</html>', {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
})
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /non-JSON/)
|
||||
})
|
||||
|
||||
// A network failure resolves — never rejects — so the page renders the failure
|
||||
// instead of tearing down on an unhandled rejection.
|
||||
test('deviceInfo resolves an error when fetch throws', async () => {
|
||||
const fetchImpl: typeof fetch = async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
assert.match(String(r.ok === false && r.error), /offline/)
|
||||
})
|
||||
|
||||
// A 200 that names no client is not a name. Falling back to ANY local string here
|
||||
// is what produced the original defect, so an absent clientId is a failure.
|
||||
test('deviceInfo refuses an ok response with no clientId', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { displayName: 'Hanzo CLI' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, false)
|
||||
})
|
||||
|
||||
// IAM already falls back to the app's name when DisplayName is empty; if one ever
|
||||
// arrives blank anyway, the label is the server-confirmed clientId — never the portal's.
|
||||
test('deviceInfo falls back to the confirmed clientId when displayName is empty', async () => {
|
||||
const { fetchImpl } = deviceInfoFetch({ status: 'ok', data: { clientId: 'hanzo-cli', displayName: '' } })
|
||||
const client = createAuthClient({ org: org(), fetchImpl })
|
||||
const r = await client.deviceInfo('K7M4P2QH')
|
||||
assert.equal(r.ok, true)
|
||||
assert.equal(r.ok && r.displayName, 'hanzo-cli')
|
||||
})
|
||||
|
||||
// getAppLogin's redirectUri is validated by IAM against the app's REGISTERED
|
||||
// list. A cross-app SSO read (the console's `hanzo-cloud` viewed from hanzo.id)
|
||||
// MUST send the downstream app's OWN redirect_uri — the portal's `/callback` is
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
ForgotRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -69,6 +70,22 @@ export interface AuthClient {
|
||||
* first (rare for first-party apps), or `{error}` with the IAM message.
|
||||
*/
|
||||
approveDevice(userCode: string): Promise<DeviceApprovalResult>
|
||||
/**
|
||||
* Name the application a pending device code belongs to, so the approval page
|
||||
* can say WHICH app it is authorizing — `GET
|
||||
* /v1/iam/oauth/device/<user_code>`, riding the same `iam_session_id` cookie
|
||||
* as {@link approveDevice}.
|
||||
*
|
||||
* Read this and render it; never `org.appName`, which is this portal's own
|
||||
* branding and names a different application than the one that minted the
|
||||
* code. IAM answers from the code's own application row.
|
||||
*
|
||||
* Session-gated and deliberately terse: an expired session comes back as
|
||||
* `loginRequired`, and unknown / expired / already-approved all come back as
|
||||
* ONE indistinguishable refusal, because a user_code is 40 bits and an
|
||||
* endpoint that told them apart would be an oracle for hunting live codes.
|
||||
*/
|
||||
deviceInfo(userCode: string): Promise<DeviceInfoResult>
|
||||
signup(req: SignupRequest): Promise<LoginResponse>
|
||||
forgot(req: ForgotRequest): Promise<{ ok: boolean; error?: string }>
|
||||
authorize(req: OAuthAuthorizeRequest): string
|
||||
@@ -302,6 +319,52 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function deviceInfo(userCode: string): Promise<DeviceInfoResult> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (!code) return { ok: false, error: 'Enter the code shown on your device.' }
|
||||
// POST, and the code rides the BODY — like `approveDevice` beside it, and for
|
||||
// the reason IAM's own introspection endpoint is POST: the user_code is the one
|
||||
// secret in this flow, and a request line is copied into ingress and proxy
|
||||
// access logs where a body is not. This page ships `scrubUrl()` to keep the
|
||||
// code out of the address bar; putting it into every request line would undo
|
||||
// that server-side. Same session cookie as the approval: whatever you may look
|
||||
// at is exactly what you may approve.
|
||||
const url = new URL('/v1/iam/oauth/device/info', org.iamUrl)
|
||||
let res: Response
|
||||
try {
|
||||
res = await f(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ userCode: code }),
|
||||
})
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) }
|
||||
}
|
||||
let parsed: Record<string, unknown> = {}
|
||||
try {
|
||||
parsed = (await res.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return { ok: false, error: `HTTP ${res.status} non-JSON response` }
|
||||
}
|
||||
if (!res.ok || parsed.status === 'error') {
|
||||
const error = typeof parsed.msg === 'string' && parsed.msg ? parsed.msg : `HTTP ${res.status}`
|
||||
// IAM `CodeLoginRequired` (internal/oidc/oidc.go): the session lapsed between
|
||||
// the page's get-account check and this read. Not a dead end — sign in again.
|
||||
if (parsed.code === 'login_required') return { ok: false, error, loginRequired: true }
|
||||
return { ok: false, error }
|
||||
}
|
||||
// A name is only worth rendering if the server sent it. An answer with no
|
||||
// clientId names nothing, so it fails rather than letting the page fall back
|
||||
// to a guess — showing the WRONG application is the defect this endpoint exists
|
||||
// to fix. `displayName` falls back to the clientId, which IAM did confirm.
|
||||
const data = parsed.data as Record<string, unknown> | undefined
|
||||
const clientId = typeof data?.clientId === 'string' ? data.clientId : ''
|
||||
const displayName = typeof data?.displayName === 'string' ? data.displayName : ''
|
||||
if (!clientId) return { ok: false, error: 'IAM did not name the application for this code.' }
|
||||
return { ok: true, clientId, displayName: displayName || clientId }
|
||||
}
|
||||
|
||||
async function signup(req: SignupRequest): Promise<LoginResponse> {
|
||||
const url = new URL('/v1/iam/signup', org.iamUrl)
|
||||
url.searchParams.set('clientId', req.clientId)
|
||||
@@ -637,6 +700,7 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
|
||||
login,
|
||||
silentLogin,
|
||||
approveDevice,
|
||||
deviceInfo,
|
||||
signup,
|
||||
forgot,
|
||||
authorize,
|
||||
|
||||
@@ -36,5 +36,6 @@ export type {
|
||||
AppLogin,
|
||||
AppProvider,
|
||||
DeviceApprovalResult,
|
||||
DeviceInfoResult,
|
||||
} from './types'
|
||||
export * from './ui'
|
||||
|
||||
@@ -108,6 +108,26 @@ export interface DeviceApprovalResult {
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH application a pending device code belongs to
|
||||
* ({@link AuthClient.deviceInfo}) — the one thing the approval page exists to
|
||||
* tell a human, and the one thing it cannot know on its own.
|
||||
*
|
||||
* Both fields come off the device code's own application row, so a page that
|
||||
* renders them names the party it is actually authorizing. They are the ONLY
|
||||
* honest source: `org.appName` is this portal's static branding and names the
|
||||
* wrong app for every code minted by anything else.
|
||||
*
|
||||
* Discriminated on `ok` so a caller cannot read `displayName` without having
|
||||
* proved the server confirmed one. `loginRequired` singles out the expired
|
||||
* session (IAM `code:"login_required"`) — the page's cue to sign the human in
|
||||
* and come back, not an error to show. Every other failure is IAM's single
|
||||
* opaque refusal, surfaced verbatim.
|
||||
*/
|
||||
export type DeviceInfoResult =
|
||||
| { readonly ok: true; readonly clientId: string; readonly displayName: string }
|
||||
| { readonly ok: false; readonly error: string; readonly loginRequired?: boolean }
|
||||
|
||||
/**
|
||||
* The TOTP enrollment material minted by `/v1/iam/mfa/setup/initiate`. The
|
||||
* secret + `url` (an `otpauth://` URI) are rendered locally as a QR code — the
|
||||
|
||||
Reference in New Issue
Block a user