🩻 refactor: Replace Opaque OAuth Errors with Structured Failure Diagnostics (#13471)

* Improve OAuth failure logging

* Improve OAuth failure logging

* test: type oauth failure request helper

* refactor: move OpenID callback helper to api package
This commit is contained in:
Danny Avila 2026-06-02 15:06:42 -04:00 committed by GitHub
parent 8ba0249f1e
commit 317b8dfbd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1032 additions and 11 deletions

View file

@ -0,0 +1,274 @@
import type { NextFunction, Response } from 'express';
import {
createOpenIDCallbackAuthenticator,
logOpenIDCallbackFailure,
redirectToAuthFailure,
type OpenIDCallbackRequest,
type OpenIDCallbackAuthenticatorOptions,
} from './callback';
type CallbackFn = (err: unknown, user: unknown, info: unknown) => void;
type TestRequest = OpenIDCallbackRequest;
const logger = {
warn: jest.fn(),
error: jest.fn(),
};
function createRequest(overrides: Partial<TestRequest> = {}): TestRequest {
return {
headers: {},
method: 'GET',
path: '/openid/callback',
originalUrl: '/openid/callback',
query: {},
...overrides,
} as TestRequest;
}
function createResponse(): Response {
return {
redirect: jest.fn(),
} as unknown as Response;
}
function createNext(): jest.MockedFunction<NextFunction> {
return jest.fn() as jest.MockedFunction<NextFunction>;
}
function createAuthenticator(
callbackHandler: (
callback: CallbackFn,
req: TestRequest,
res: Response,
next: NextFunction,
) => void,
) {
const passport = {
authenticate: jest.fn((_strategy: 'openid', _options, callback: CallbackFn) => {
return (req: TestRequest, res: Response, next: NextFunction) => {
callbackHandler(callback, req, res, next);
};
}),
};
const options: OpenIDCallbackAuthenticatorOptions = {
passport,
logger,
clientDomain: 'http://client.test',
authFailedError: 'auth_failed',
};
return {
middleware: createOpenIDCallbackAuthenticator(options),
passport,
};
}
describe('OpenID OAuth callback helpers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('redirects failed auth attempts to the login failure URL', () => {
const res = createResponse();
redirectToAuthFailure(res, {
clientDomain: 'http://client.test',
authFailedError: 'auth_failed',
});
expect(res.redirect).toHaveBeenCalledWith(
'http://client.test/login?redirect=false&error=auth_failed',
);
});
it('logs OpenID callback failures with structured OAuth context', () => {
const req = createRequest({
query: {
code: 'secret-code',
state: 'secret-state',
},
});
const error = Object.assign(new Error('invalid response encountered'), {
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
});
logOpenIDCallbackFailure({
logger,
req,
err: error,
info: { message: 'provider info' },
});
expect(logger.warn).toHaveBeenCalledWith(
'[OpenID OAuth] Callback authentication failed',
expect.objectContaining({
provider: 'openid',
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
message: 'invalid response encountered',
has_code: true,
has_state: true,
}),
);
});
it('continues the successful callback path after logging in without a session', () => {
const user = { id: 'user-1' };
const req = createRequest();
const res = createResponse();
const next = createNext();
const logIn = jest.fn((loginUser, _options, done) => {
req.user = loginUser;
done();
});
req.logIn = logIn;
const { middleware, passport } = createAuthenticator((callback) =>
callback(null, user, { message: 'ok' }),
);
middleware(req, res, next);
expect(passport.authenticate).toHaveBeenCalledWith(
'openid',
{ failureMessage: true, session: false },
expect.any(Function),
);
expect(logIn).toHaveBeenCalledWith(user, { session: false }, expect.any(Function));
expect(req.user).toBe(user);
expect(next).toHaveBeenCalledWith();
expect(logger.warn).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
it('sets req.user and continues when req.logIn is unavailable', () => {
const user = { id: 'user-1' };
const req = createRequest();
const res = createResponse();
const next = createNext();
const { middleware } = createAuthenticator((callback) => callback(null, user, undefined));
middleware(req, res, next);
expect(req.user).toBe(user);
expect(next).toHaveBeenCalledWith();
});
it('logs OpenID protocol failures and redirects without escalating', () => {
const req = createRequest({
query: {
code: 'secret-code',
state: 'secret-state',
},
});
const res = createResponse();
const next = createNext();
const error = Object.assign(new Error('invalid response encountered'), {
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
});
const { middleware } = createAuthenticator((callback) => callback(error, false, undefined));
middleware(req, res, next);
expect(logger.warn).toHaveBeenCalledWith(
'[OpenID OAuth] Callback authentication failed',
expect.objectContaining({
provider: 'openid',
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
message: 'invalid response encountered',
has_code: true,
has_state: true,
}),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://client.test/login?redirect=false&error=auth_failed',
);
expect(next).not.toHaveBeenCalled();
});
it('logs unexpected OpenID errors with context before escalating', () => {
const req = createRequest({
query: {
code: 'secret-code',
state: 'secret-state',
},
});
const res = createResponse();
const next = createNext();
const error = Object.assign(new Error('database exploded'), {
name: 'DatabaseError',
});
const { middleware } = createAuthenticator((callback) => callback(error, false, undefined));
middleware(req, res, next);
expect(logger.error).toHaveBeenCalledWith(
'[OpenID OAuth] Callback authentication error',
expect.objectContaining({
provider: 'openid',
name: 'DatabaseError',
message: 'database exploded',
has_code: true,
has_state: true,
}),
);
expect(next).toHaveBeenCalledWith(error);
expect(res.redirect).not.toHaveBeenCalled();
});
it('logs Passport info failures and redirects without escalating', () => {
const req = createRequest();
const res = createResponse();
const next = createNext();
const { middleware } = createAuthenticator((callback) =>
callback(null, false, {
code: 'DOMAIN_DENIED',
message: 'Email domain not allowed',
}),
);
middleware(req, res, next);
expect(logger.warn).toHaveBeenCalledWith(
'[OpenID OAuth] Callback authentication failed',
expect.objectContaining({
provider: 'openid',
code: 'DOMAIN_DENIED',
message: 'Email domain not allowed',
}),
);
expect(res.redirect).toHaveBeenCalledWith(
'http://client.test/login?redirect=false&error=auth_failed',
);
expect(next).not.toHaveBeenCalled();
});
it('logs login errors and sends them to the error handler', () => {
const user = { id: 'user-1' };
const req = createRequest();
const res = createResponse();
const next = createNext();
const error = Object.assign(new Error('login failed'), {
name: 'LoginError',
});
req.logIn = jest.fn((_loginUser, _options, done) => done(error));
const { middleware } = createAuthenticator((callback) =>
callback(null, user, { message: 'provider info' }),
);
middleware(req, res, next);
expect(logger.error).toHaveBeenCalledWith(
'[OpenID OAuth] Callback authentication error',
expect.objectContaining({
provider: 'openid',
name: 'LoginError',
message: 'login failed',
}),
);
expect(next).toHaveBeenCalledWith(error);
});
});

View file

@ -0,0 +1,140 @@
import type { NextFunction, Response } from 'express';
import {
buildOAuthFailureLog,
isOAuthProtocolFailure,
type OAuthFailureLog,
type OAuthFailureRequest,
} from './failure';
type LoginFunction = (
user: unknown,
options: { session: false },
done: (err?: unknown) => void,
) => void;
export type OpenIDCallbackRequest = OAuthFailureRequest & {
logIn?: LoginFunction;
user?: unknown;
};
type OpenIDCallback = (err: unknown, user: unknown, info: unknown) => void;
type PassportMiddleware = (
req: OpenIDCallbackRequest,
res: Response,
next: NextFunction,
) => unknown;
type PassportLike = {
authenticate: (
strategy: 'openid',
options: {
failureMessage: true;
session: false;
},
callback: OpenIDCallback,
) => PassportMiddleware;
};
type OAuthCallbackLogLevel = 'warn' | 'error';
type OAuthCallbackLogger = Record<
OAuthCallbackLogLevel,
(message: string, details: OAuthFailureLog) => void
>;
export type AuthFailureRedirectOptions = {
clientDomain?: string;
authFailedError: string;
};
export type LogOpenIDCallbackFailureOptions = {
logger: OAuthCallbackLogger;
req: OAuthFailureRequest;
err?: unknown;
info?: unknown;
level?: OAuthCallbackLogLevel;
};
export type OpenIDCallbackAuthenticatorOptions = AuthFailureRedirectOptions & {
logger: OAuthCallbackLogger;
passport: PassportLike;
};
export function redirectToAuthFailure(
res: Response,
{ clientDomain, authFailedError }: AuthFailureRedirectOptions,
): void {
res.redirect(`${clientDomain}/login?redirect=false&error=${authFailedError}`);
}
export function logOpenIDCallbackFailure({
logger,
req,
err,
info,
level = 'warn',
}: LogOpenIDCallbackFailureOptions): void {
logger[level](
level === 'error'
? '[OpenID OAuth] Callback authentication error'
: '[OpenID OAuth] Callback authentication failed',
buildOAuthFailureLog({
provider: 'openid',
req,
err,
info,
defaultMessage: 'OpenID authentication failed',
}),
);
}
export function createOpenIDCallbackAuthenticator({
passport,
logger,
clientDomain,
authFailedError,
}: OpenIDCallbackAuthenticatorOptions): (
req: OpenIDCallbackRequest,
res: Response,
next: NextFunction,
) => unknown {
return (req: OpenIDCallbackRequest, res: Response, next: NextFunction): unknown => {
return passport.authenticate(
'openid',
{
failureMessage: true,
session: false,
},
(err: unknown, user: unknown, info: unknown) => {
if (err) {
if (isOAuthProtocolFailure(err, info)) {
logOpenIDCallbackFailure({ logger, req, err, info });
return redirectToAuthFailure(res, { clientDomain, authFailedError });
}
logOpenIDCallbackFailure({ logger, req, err, info, level: 'error' });
return next(err);
}
if (!user) {
logOpenIDCallbackFailure({ logger, req, err, info });
return redirectToAuthFailure(res, { clientDomain, authFailedError });
}
if (typeof req.logIn !== 'function') {
req.user = user;
return next();
}
return req.logIn(user, { session: false }, (loginErr?: unknown) => {
if (loginErr) {
logOpenIDCallbackFailure({ logger, req, err: loginErr, info, level: 'error' });
return next(loginErr);
}
return next();
});
},
)(req, res, next);
};
}

View file

@ -0,0 +1,139 @@
import { buildOAuthFailureLog, getOAuthFailureMessage, isOAuthProtocolFailure } from './failure';
import type { OAuthFailureRequest } from './failure';
function createRequest(overrides: Partial<OAuthFailureRequest> = {}): OAuthFailureRequest {
return {
headers: {},
method: 'GET',
path: '/openid/callback',
originalUrl: '/openid/callback',
query: {},
...overrides,
};
}
describe('OAuth failure logging helpers', () => {
it('prefers session failure messages and removes the consumed message', () => {
const req = createRequest({
session: {
messages: ['first', 'latest'],
},
});
expect(getOAuthFailureMessage(req)).toBe('latest');
expect(req.session?.messages).toEqual(['first']);
});
it('falls back to provider query error details without returning Unknown OAuth error', () => {
const req = createRequest({
query: {
error: 'access_denied',
error_description: 'Denied by provider',
},
});
expect(getOAuthFailureMessage(req)).toBe('Denied by provider');
});
it('logs OpenID protocol failure metadata without raw code or state values', () => {
const req = createRequest({
headers: {
host: 'chat.example.com',
'x-forwarded-for': '203.0.113.10',
'x-forwarded-proto': 'https',
'user-agent': 'test-agent',
},
id: 'request-id',
originalUrl: '/openid/callback?code=secret-code&state=secret-state',
query: {
code: 'secret-code',
state: 'secret-state',
},
});
const error = Object.assign(new Error('invalid response encountered'), {
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
cause: {
code: 'OAUTH_INVALID_RESPONSE',
name: 'OperationProcessingError',
message: 'invalid response encountered',
},
});
const log = buildOAuthFailureLog({
provider: 'openid',
req,
err: error,
defaultMessage: 'OpenID authentication failed',
});
expect(log).toEqual({
provider: 'openid',
code: 'OAUTH_INVALID_RESPONSE',
name: 'ClientError',
message: 'invalid response encountered',
cause_code: 'OAUTH_INVALID_RESPONSE',
cause_name: 'OperationProcessingError',
cause_message: 'invalid response encountered',
has_code: true,
has_state: true,
method: 'GET',
path: '/openid/callback',
request_id: 'request-id',
host: 'chat.example.com',
forwarded_proto: 'https',
forwarded_for: '203.0.113.10',
user_agent: 'test-agent',
});
expect(JSON.stringify(log)).not.toContain('secret-code');
expect(JSON.stringify(log)).not.toContain('secret-state');
});
it('captures provider response error fields from Passport info', () => {
const log = buildOAuthFailureLog({
provider: 'openid',
req: createRequest(),
info: {
error: 'access_denied',
error_description: 'User denied consent',
},
});
expect(log).toEqual({
provider: 'openid',
code: 'access_denied',
message: 'User denied consent',
has_code: false,
has_state: false,
method: 'GET',
path: '/openid/callback',
});
});
it('truncates very long messages', () => {
const longMessage = 'x'.repeat(320);
const log = buildOAuthFailureLog({
provider: 'openid',
req: createRequest(),
info: {
message: longMessage,
},
});
expect(log.message).toHaveLength(315);
expect(log.message?.endsWith('... [truncated]')).toBe(true);
});
it.each([
[{ code: 'OAUTH_INVALID_RESPONSE' }, true],
[{ name: 'AuthorizationResponseError' }, true],
[
{ cause: { name: 'OperationProcessingError', message: 'invalid response encountered' } },
true,
],
[{ name: 'DatabaseError', message: 'database exploded' }, false],
])('classifies OAuth protocol failure %j as %s', (error, expected) => {
expect(isOAuthProtocolFailure(error)).toBe(expected);
});
});

View file

@ -0,0 +1,258 @@
import type { Request } from 'express';
const MAX_LOG_VALUE_LENGTH = 300;
type LogValue = string | boolean;
type FailureLike = {
code?: unknown;
error?: unknown;
name?: unknown;
message?: unknown;
error_description?: unknown;
cause?: unknown;
};
export type OAuthFailureRequest = Pick<
Request,
'headers' | 'method' | 'path' | 'originalUrl' | 'query'
> & {
id?: string;
requestId?: string;
session?: {
messages?: unknown[];
};
};
export type OAuthFailureLog = {
provider: string;
code?: string;
name?: string;
message?: string;
cause_code?: string;
cause_name?: string;
cause_message?: string;
has_code: boolean;
has_state: boolean;
query_error?: string;
query_error_description?: string;
method?: string;
path?: string;
request_id?: string;
host?: string;
forwarded_host?: string;
forwarded_proto?: string;
forwarded_for?: string;
real_ip?: string;
user_agent?: string;
};
export type BuildOAuthFailureLogParams = {
provider: string;
req: OAuthFailureRequest;
err?: unknown;
info?: unknown;
defaultMessage?: string;
};
function normalizeLogValue(value: unknown): string | undefined {
if (value == null) {
return undefined;
}
if (Array.isArray(value)) {
for (const entry of value) {
const normalized = normalizeLogValue(entry);
if (normalized) {
return normalized;
}
}
return undefined;
}
if (typeof value === 'string') {
const trimmed = value.replace(/\s+/g, ' ').trim();
if (!trimmed) {
return undefined;
}
if (trimmed.length <= MAX_LOG_VALUE_LENGTH) {
return trimmed;
}
return `${trimmed.slice(0, MAX_LOG_VALUE_LENGTH)}... [truncated]`;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return undefined;
}
function compactLogObject(
log: Partial<OAuthFailureLog> & Pick<OAuthFailureLog, 'provider' | 'has_code' | 'has_state'>,
): OAuthFailureLog {
const compacted: Partial<OAuthFailureLog> = {};
const keys = Object.keys(log) as Array<keyof OAuthFailureLog>;
for (const key of keys) {
const value = log[key];
if (value !== undefined) {
Object.assign(compacted, { [key]: value as LogValue });
}
}
return compacted as OAuthFailureLog;
}
function getField(source: unknown, field: keyof FailureLike): unknown {
if (!source) {
return undefined;
}
if (typeof source === 'string') {
return field === 'message' ? source : undefined;
}
if (typeof source === 'object') {
return (source as FailureLike)[field];
}
return undefined;
}
function firstLogValue(...values: unknown[]): string | undefined {
for (const value of values) {
const normalized = normalizeLogValue(value);
if (normalized) {
return normalized;
}
}
return undefined;
}
function getCause(source: unknown): unknown {
const cause = getField(source, 'cause');
return cause && typeof cause === 'object' ? cause : undefined;
}
function getHeader(req: OAuthFailureRequest, headerName: string): string | undefined {
return normalizeLogValue(req.headers?.[headerName]);
}
function getQueryValue(req: OAuthFailureRequest, queryName: string): string | undefined {
return normalizeLogValue(req.query?.[queryName]);
}
function hasQueryValue(req: OAuthFailureRequest, queryName: string): boolean {
return getQueryValue(req, queryName) !== undefined;
}
function getRequestPath(req: OAuthFailureRequest): string | undefined {
return firstLogValue(req.path, req.originalUrl?.split('?')[0]);
}
function popSessionFailureMessage(req: OAuthFailureRequest): unknown {
const messages = req.session?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return undefined;
}
return messages.pop();
}
export function getOAuthFailureMessage(
req: OAuthFailureRequest,
defaultMessage = 'OAuth authentication failed',
): string {
return (
firstLogValue(
popSessionFailureMessage(req),
getQueryValue(req, 'error_description'),
getQueryValue(req, 'error'),
defaultMessage,
) ?? defaultMessage
);
}
export function buildOAuthFailureLog({
provider,
req,
err,
info,
defaultMessage,
}: BuildOAuthFailureLogParams): OAuthFailureLog {
const errCause = getCause(err);
const infoCause = getCause(info);
return compactLogObject({
provider,
code: firstLogValue(
getField(err, 'code'),
getField(err, 'error'),
getField(errCause, 'code'),
getField(errCause, 'error'),
getField(info, 'code'),
getField(info, 'error'),
getField(infoCause, 'code'),
getField(infoCause, 'error'),
getQueryValue(req, 'error'),
),
name: firstLogValue(getField(err, 'name'), getField(info, 'name')),
message: firstLogValue(
getField(err, 'message'),
getField(err, 'error_description'),
getField(info, 'message'),
getField(info, 'error_description'),
getQueryValue(req, 'error_description'),
getQueryValue(req, 'error'),
defaultMessage,
),
cause_code: firstLogValue(getField(errCause, 'code'), getField(infoCause, 'code')),
cause_name: firstLogValue(getField(errCause, 'name'), getField(infoCause, 'name')),
cause_message: firstLogValue(getField(errCause, 'message'), getField(infoCause, 'message')),
has_code: hasQueryValue(req, 'code'),
has_state: hasQueryValue(req, 'state'),
query_error: getQueryValue(req, 'error'),
query_error_description: getQueryValue(req, 'error_description'),
method: normalizeLogValue(req.method),
path: getRequestPath(req),
request_id: firstLogValue(req.requestId, req.id, getHeader(req, 'x-request-id')),
host: getHeader(req, 'host'),
forwarded_host: getHeader(req, 'x-forwarded-host'),
forwarded_proto: getHeader(req, 'x-forwarded-proto'),
forwarded_for: getHeader(req, 'x-forwarded-for'),
real_ip: getHeader(req, 'x-real-ip'),
user_agent: getHeader(req, 'user-agent'),
});
}
export function isOAuthProtocolFailure(err?: unknown, info?: unknown): boolean {
const errCause = getCause(err);
const infoCause = getCause(info);
const code = firstLogValue(
getField(err, 'code'),
getField(err, 'error'),
getField(errCause, 'code'),
getField(errCause, 'error'),
getField(info, 'code'),
getField(info, 'error'),
getField(infoCause, 'code'),
getField(infoCause, 'error'),
);
if (code?.startsWith('OAUTH_')) {
return true;
}
const name = firstLogValue(
getField(err, 'name'),
getField(errCause, 'name'),
getField(info, 'name'),
getField(infoCause, 'name'),
);
if (name === 'AuthorizationResponseError') {
return true;
}
const message = firstLogValue(
getField(err, 'message'),
getField(errCause, 'message'),
getField(info, 'message'),
getField(infoCause, 'message'),
);
return name === 'OperationProcessingError' && /invalid response/i.test(message ?? '');
}

View file

@ -1,3 +1,5 @@
export * from './csrf';
export * from './callback';
export * from './failure';
export * from './tokens';
export * from './validation';