Skip to content
25 changes: 21 additions & 4 deletions server/src/auth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,27 @@ export async function handleCallback(request: FastifyRequest, reply: FastifyRepl
// inspected; DUOS_OAUTH_REDIRECT_URI supplies the base so it parses as an
// absolute URL.
const currentUrl = new URL(request.url, requireEnv('DUOS_OAUTH_REDIRECT_URI'))
const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: request.session.pkceVerifier,
expectedState: request.session.pkceState,
})
let tokens: Awaited<ReturnType<typeof oidc.authorizationCodeGrant>>
try {
tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: request.session.pkceVerifier,
expectedState: request.session.pkceState,
})
}
catch (err: unknown) {
if (err instanceof oidc.AuthorizationResponseError) {
// B2C answered the authorization request with an error instead of a
// code — the user canceled on the B2C page (access_denied), or B2C
// itself failed. Land back in the SPA instead; a cancel is the user's
// own action and stays silent. A cancel is also routine — info keeps
// it out of warn-based alerting; real provider errors stay at warn.
const cancelled = err.error === 'access_denied'
request.log[cancelled ? 'info' : 'warn']({ error: err.error, description: err.error_description }, '[auth] B2C authorization response is an error')
reply.redirect(cancelled ? '/' : '/?signInError=provider')
return
}
throw err
}

const claims = tokens.claims() // undefined when no id_token is present

Expand Down
161 changes: 98 additions & 63 deletions server/src/auth/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,84 @@ import { REFRESH_WINDOW_SECONDS, RefreshFailedError, refreshAccessToken } from '
const UPSTREAM_TIMEOUT_MS = 5000

/**
* Answers an upstream 401/404. The DUOS API conflates "bad token" with "no
* DUOS profile for this email" (both 401 — DuosUserAuthenticator turns the
* lookup's NotFoundException into an empty principal), so the session's
* `profileSeen` flag is the disambiguator this endpoint controls.
* Shown when the upstream 409 arrives without a usable message — the client
* needs something actionable even if the body was empty or malformed.
*/
async function answerNoProfile(request: FastifyRequest, reply: FastifyReply): Promise<void> {
if (request.session.profileSeen) {
// This session has served a profile before, so "no profile" cannot be
// the explanation. The upstream is rejecting the token itself — revoked
// mid-lifetime (before refresh-before-forward would touch it) or the
// account was disabled. The terminal 401 is the honest answer; leaving
// the session alive would send the client into re-registering an
// existing user.
try {
await request.session.destroy()
const PROVIDER_CONFLICT_FALLBACK_MESSAGE
= 'You may have previously signed in with a different authentication provider (Google or Microsoft). Please sign in with that provider.'

/**
* Ends a session the upstream has authoritatively rejected.
*/
async function destroySession(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
await request.session.destroy()
}
catch (err: unknown) {
request.log.error({ err }, '[auth] upstream rejected the session but it could not be destroyed — answering as signed out anyway')
}
reply.clearCookie('sessionId')
}

/**
* Refresh-before-forward, mirroring the API proxy: an idle tab can outlive
* the access token while the refresh token and session are still perfectly
* valid. Forwarding the expired token would 401 upstream and destroy a
* session that only needed a refresh — and the client's focus revalidation
* makes this exact path hot.
*
* Returns false when the refresh failed and the reply has already gone out.
*/
async function refreshedIfExpiring(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const secondsRemaining = (request.session.tokenExpiry ?? 0) - Math.floor(Date.now() / 1000)
if (secondsRemaining >= REFRESH_WINDOW_SECONDS) return true
try {
await refreshAccessToken(request)
return true
}
catch (err: unknown) {
if (err instanceof RefreshFailedError) {
// Terminal: B2C rejected the refresh token and refreshAccessToken has
// already destroyed the session — clear the dead cookie.
reply.clearCookie('sessionId').status(401).send({ authenticated: false })
return false
}
catch (err: unknown) {
request.log.error({ err }, '[auth] upstream rejected a profile-seen session but it could not be destroyed — returning 401 anyway')
// Transient (network blip, B2C 5xx, store error) — the session is
// intact, so this must not read as signed out permanently: 502 tells
// the client probe to retry on the next ask.
reply.status(502).send({ authenticated: false, error: 'upstream_unavailable' })
return false
}
}

/**
* The actionable message from the upstream 409 body ({ message, code }), or
* the fallback when the body is empty, malformed, or not a string.
*/
async function providerConflictMessage(res: Response): Promise<string> {
try {
const body: unknown = await res.json()
const upstreamMessage = (body as { message?: unknown } | null)?.message
if (typeof upstreamMessage === 'string' && upstreamMessage.length > 0) {
return upstreamMessage
}
reply.clearCookie('sessionId').status(401).send({ authenticated: false })
return
}
// Never seen a profile: authenticated but not yet registered. Treating
// this 401 as a dead session destroyed every brand-new user's session on
// their first probe and made registration unreachable. In the rare case
// of a token revoked before first contact, the client's registration
// attempt fails too, which signs the session out. (404 kept deliberately:
// DT-3997 restores the upstream's 404 for unregistered users.)
reply.send({ authenticated: true, idp: request.session.idp })
catch {
// Unparseable body — the fallback message stands.
}
return PROVIDER_CONFLICT_FALLBACK_MESSAGE
}

/**
* Confirms the user is authenticated against the upstream Consent API.
* Forwards the upstream user profile and the active sub-provider — never the
* tokens themselves, which stay server-side in the session.
*
* The upstream /api/user/me contract:
* - 200 registered profile
* - 401 rejected token
* - 404 authenticated but unregistered
* - 409 Sam sub-provider conflict
*/
export async function getMe(request: FastifyRequest, reply: FastifyReply): Promise<void> {
// The answer is per-session and now gates the whole SPA: no intermediary
Expand All @@ -53,30 +96,7 @@ export async function getMe(request: FastifyRequest, reply: FastifyReply): Promi
return
}

// Refresh-before-forward, mirroring the API proxy: an idle tab can outlive
// the access token while the refresh token and session are still perfectly
// valid. Forwarding the expired token would 401 upstream and destroy a
// session that only needed a refresh — and the client's focus revalidation
// makes this exact path hot.
const secondsRemaining = (request.session.tokenExpiry ?? 0) - Math.floor(Date.now() / 1000)
if (secondsRemaining < REFRESH_WINDOW_SECONDS) {
try {
await refreshAccessToken(request)
}
catch (err: unknown) {
if (err instanceof RefreshFailedError) {
// Terminal: B2C rejected the refresh token and refreshAccessToken has
// already destroyed the session — clear the dead cookie.
reply.clearCookie('sessionId').status(401).send({ authenticated: false })
return
}
// Transient (network blip, B2C 5xx, store error) — the session is
// intact, so this must not read as signed out permanently: 502 tells
// the client probe to retry on the next ask.
reply.status(502).send({ authenticated: false, error: 'upstream_unavailable' })
return
}
}
if (!(await refreshedIfExpiring(request, reply))) return

const url = `${requireEnv('DUOS_API_URL')}/api/user/me`

Expand All @@ -98,15 +118,39 @@ export async function getMe(request: FastifyRequest, reply: FastifyReply): Promi
return
}

if (res.status === 401 || res.status === 404) {
await answerNoProfile(request, reply)
if (res.status === 401) {
// The upstream rejected the token itself — revoked mid-lifetime (before refresh-before-forward would
// touch it) or the account was disabled. The terminal 401 is final so the session must be destroyed.
// The client still needs to know it is signed out, so the reply goes out after the session is destroyed.
await destroySession(request, reply)
reply.status(401).send({ authenticated: false })
return
}

if (res.status === 404) {
// Authenticated but not yet registered — the session is valid, and the
// client's post-sign-in bootstrap needs authenticated:true (with user
// absent) to reach its registration flow.
reply.send({ authenticated: true, idp: request.session.idp })
return
}

if (res.status === 409) {
// Sam sub-provider conflict: the account exists under the other B2C sub-provider and Sam rejects it.
// Registration cannot succeed and the session cannot become usable, so end it — but forward the
// upstream's actionable message (sign in with the other provider, plus the support link) instead
// of a generic failure.
const message = await providerConflictMessage(res)
await destroySession(request, reply)
reply.status(409).send({ authenticated: false, error: 'provider_conflict', message })
return
}

if (!res.ok) {
// A non-401 failure (5xx, upstream outage) says nothing about whether the
// token itself is still valid — don't destroy the session or parse an
// error body as if it were a user profile.
// An unmapped status — 5xx, an upstream outage, or a 4xx the contract
// does not define (400/403/429) — says nothing about whether the token
// itself is still valid: don't destroy the session or parse an error
// body as if it were a user profile. 502 tells the probe to retry.
reply.status(502).send({ authenticated: false, error: 'upstream_unavailable' })
return
}
Expand All @@ -120,15 +164,6 @@ export async function getMe(request: FastifyRequest, reply: FastifyReply): Promi
return
}

if (!request.session.profileSeen) {
request.session.profileSeen = true
// Saved explicitly before the reply, once per session (the flag never
// flips back): with rolling off, the onSend hook only skips its async
// save when the session is unmodified — see index.ts on the
// ERR_HTTP_HEADERS_SENT hazard a post-reply async save creates.
await request.session.save()
}

reply.send({
authenticated: true,
user,
Expand Down
5 changes: 0 additions & 5 deletions server/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,5 @@ declare module 'fastify' {
// regardless — this field exists for the audit trail and observability,
// not client selection.
idp?: 'google' | 'microsoft'
// Set once /auth/me has served a DUOS profile on this session. The
// upstream conflates "no profile" with "bad token" (both 401), and this
// is the disambiguator: a 401/404 on a profile-seen session cannot mean
// "unregistered", so it is treated as a terminal token verdict.
profileSeen?: boolean
}
}
35 changes: 35 additions & 0 deletions server/test/authCrypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,41 @@ describe('B2C OAuth callback (real openid-client validation against a fake B2C)'
expect(res.statusCode).toBe(500)
})

it('redirects to /?signInError=provider when B2C answers with an error instead of a code', async () => {
// Edge case: B2C cannot complete the federated sign-in (observed when a
// tenant's federation client secret to the upstream Microsoft provider
// has expired — every Microsoft account then fails). B2C redirects back
// with error=server_error — the real authorizationCodeGrant throws
// AuthorizationResponseError, which must land the browser in the SPA
const { cookie, state } = await login()

const res = await app.inject({
method: 'GET',
url: `/auth/callback?error=server_error&error_description=AADB2C90289&state=${state}`,
headers: { cookie },
})

expect(res.statusCode).toBe(302)
expect(res.headers.location).toBe('/?signInError=provider')
const sess = [...rows.values()][0].sess as Record<string, unknown>
expect(sess.accessToken).toBeUndefined()
})

it('redirects home silently when the user cancels on the B2C page (access_denied)', async () => {
const { cookie, state } = await login()

const res = await app.inject({
method: 'GET',
url: `/auth/callback?error=access_denied&error_description=AADB2C90091&state=${state}`,
headers: { cookie },
})

expect(res.statusCode).toBe(302)
expect(res.headers.location).toBe('/')
const sess = [...rows.values()][0].sess as Record<string, unknown>
expect(sess.accessToken).toBeUndefined()
})

it('returns 400 token_missing_email_claim when a valid id_token has no email', async () => {
// The token is cryptographically valid — the grant succeeds — so this
// exercises the handler guard on top of real validation, not a crypto error.
Expand Down
Loading
Loading