fix(auth): thread OIDC nonce through the password-login path

Confidential OIDC clients that validate strictly (LibreChat openid-client with
OPENID_REUSE_TOKENS) send a nonce on authorize and require the id_token to echo
it. The portal's password-login path read code_challenge from the authorize URL
but DROPPED nonce, so the IAM minted a code (and id_token) with no nonce ->
openid-client 'unexpected JWT claim value encountered' -> hanzo.chat callback
HTTP 500. Thread nonce: Login.tsx reads ?nonce, LoginForm forwards it, and
client.login() puts it on the /v1/iam/login query (next to code_challenge).
Additive + forward-only (never defaulted); social login already rode authorize()
which carried nonce. Verified: id SPA builds green.
This commit is contained in:
zooqueen
2026-06-23 18:17:30 -07:00
parent d3260fec67
commit da05c24f51
4 changed files with 17 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
const clientIdOverride = sp.get('client_id') ?? 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-login">
<BrandHeader brand={brand} />
@@ -27,6 +28,7 @@ export function Login({ client, brand }: { client: AuthClient; brand: BrandContr
clientIdOverride={clientIdOverride ?? undefined}
codeChallenge={codeChallenge}
codeChallengeMethod={codeChallengeMethod}
nonce={nonce}
/>
<p className="hanzo-id-footer-links">
<a href="/forget">Forgot password?</a> · <a href="/signup">Create account</a>
+4
View File
@@ -91,6 +91,10 @@ export function createAuthClient(opts: AuthClientOptions): AuthClient {
if (req.redirectUri) url.searchParams.set('redirectUri', req.redirectUri)
url.searchParams.set('scope', 'openid profile email')
if (req.state) url.searchParams.set('state', req.state)
// Echo the downstream OIDC nonce so the minted code -> id_token carries it.
// Strict openid-client consumers (LibreChat OPENID_REUSE_TOKENS) reject an
// id_token whose nonce != the one they sent ("unexpected JWT claim value").
if (req.nonce) url.searchParams.set('nonce', req.nonce)
if (req.codeChallenge) {
url.searchParams.set('code_challenge', req.codeChallenge)
url.searchParams.set('code_challenge_method', req.codeChallengeMethod ?? 'S256')
+9
View File
@@ -26,6 +26,15 @@ export interface LoginRequest {
readonly state?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
/**
* OIDC nonce from the downstream authorize request. MUST be threaded through
* the password-login path so the minted code (and resulting id_token) echo it.
* Confidential OIDC clients that validate strictly (e.g. LibreChat /
* openid-client with OPENID_REUSE_TOKENS) reject an id_token whose nonce
* doesn't match the one they sent -> "unexpected JWT claim value" -> callback
* 500. Forward, never default — only echo what the authorize URL carried.
*/
readonly nonce?: string
}
export interface LoginResponse {
+2
View File
@@ -9,6 +9,7 @@ export interface LoginFormProps {
readonly clientIdOverride?: string
readonly codeChallenge?: string
readonly codeChallengeMethod?: 'S256' | 'plain'
readonly nonce?: string
readonly onSuccess?: (res: LoginResponse) => void
readonly onMfaRequired?: (res: LoginResponse) => void
}
@@ -40,6 +41,7 @@ export function LoginForm(props: LoginFormProps) {
state: props.state,
codeChallenge: props.codeChallenge,
codeChallengeMethod: props.codeChallengeMethod,
nonce: props.nonce,
})
if (res.error) {
setError(res.error)