Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
742 changes: 32 additions & 710 deletions packages/devtools-core/src/diagnosis/diagnosis-engine.ts

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions packages/devtools-core/src/diagnosis/rules/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { AuthEvent } from '@wolfcola/devtools-types';
import type { IssueCandidate } from './types.js';

export function collectCorsIssues(events: readonly AuthEvent[]): IssueCandidate[] {
const candidates: IssueCandidate[] = [];

for (const event of events) {
if (event.data._tag !== 'network') continue;
const { data } = event;
const origin = data.requestHeaders['origin'] ?? '';
const allowOrigin = data.responseHeaders['access-control-allow-origin'] ?? '';
const allowCredentials = data.responseHeaders['access-control-allow-credentials'] ?? '';
const hasOriginHeader = 'origin' in data.requestHeaders;

if (data.status === 0 && event.flags.isCors) {
candidates.push({
dedupKey: `cors:status-zero:${origin}`,
eventId: event.id,
issue: {
id: 'cors:status-zero',
severity: 'error',
category: 'cors',
title: 'Network failure (status 0)',
description:
'The request never reached the server. This is almost always a CORS preflight rejection.',
steps: [
`Your auth server must include this origin in allowed origins: ${origin || '(unknown)'}`,
'Check the OPTIONS preflight request in the Network tab.',
'If using credentials, wildcard (*) is not allowed as the allowed origin.',
],
relevantData: origin ? { origin } : undefined,
},
});
}

if (hasOriginHeader && !allowOrigin && data.status !== 0 && event.flags.isCors) {
candidates.push({
dedupKey: `cors:missing-allow-origin:${origin}`,
eventId: event.id,
issue: {
id: 'cors:missing-allow-origin',
severity: 'error',
category: 'cors',
title: 'Missing CORS header',
description: 'The server response is missing Access-Control-Allow-Origin.',
steps: [
`Add ${origin} to allowed origins on your auth server.`,
'Verify the request origin matches what is configured in your AS CORS settings.',
],
relevantData: { 'missing-header': 'access-control-allow-origin', origin },
},
});
}

if (allowOrigin === '*' && allowCredentials === 'true') {
candidates.push({
dedupKey: `cors:wildcard-with-credentials:${data.url}`,
eventId: event.id,
issue: {
id: 'cors:wildcard-with-credentials',
severity: 'error',
category: 'cors',
title: 'Wildcard CORS with credentials',
description:
'access-control-allow-origin: * cannot be used together with access-control-allow-credentials: true.',
steps: [
`Replace wildcard with an explicit origin: ${origin || '(your app origin)'}`,
'Configure your auth server to reflect the specific requesting origin.',
],
relevantData: {
'access-control-allow-origin': '*',
'access-control-allow-credentials': 'true',
},
},
});
}

if (
hasOriginHeader &&
allowCredentials === 'false' &&
data.requestHeaders['cookie'] !== undefined
) {
candidates.push({
dedupKey: `cors:credentials-not-allowed:${origin}`,
eventId: event.id,
issue: {
id: 'cors:credentials-not-allowed',
severity: 'warning',
category: 'cors',
title: 'Credentials not allowed by server',
description:
'The server set access-control-allow-credentials: false but cookies were sent.',
steps: [
'Enable credentials on the auth server CORS config.',
'Or remove the cookie from the request.',
],
},
});
}
}

return candidates;
}
147 changes: 147 additions & 0 deletions packages/devtools-core/src/diagnosis/rules/dpop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { AuthEvent } from '@wolfcola/devtools-types';
import { decodeJwtPayload } from '../../annotators/jwt-utils.js';
import type { IssueCandidate } from './types.js';

export function collectDpopIssues(events: readonly AuthEvent[]): IssueCandidate[] {
const candidates: IssueCandidate[] = [];

for (const event of events) {
const sem = event.oidcSemantics;
if (!sem?.dpop) continue;

if (event.data._tag !== 'network') continue;
const { data } = event;

// Check DPoP proof structure
if (sem.dpop.proofJwt) {
const payload = decodeJwtPayload(sem.dpop.proofJwt);
if (payload) {
const requiredClaims = ['htm', 'htu', 'iat', 'jti'];
const missing = requiredClaims.filter((c) => !(c in payload));
if (missing.length > 0) {
candidates.push({
dedupKey: `dpop:invalid-structure:${event.id}`,
eventId: event.id,
issue: {
id: 'dpop:invalid-structure',
severity: 'error',
category: 'dpop',
title: 'DPoP proof missing required claims',
description: `The DPoP proof JWT is missing: ${missing.join(', ')}.`,
steps: [
'Include all required claims: htm, htu, iat, jti.',
'Add ath when using DPoP with resource requests.',
],
relevantData: { 'missing-claims': missing.join(', ') },
},
});
}

// htm mismatch
if (typeof payload['htm'] === 'string' && payload['htm'] !== data.method) {
candidates.push({
dedupKey: `dpop:method-mismatch:${event.id}`,
eventId: event.id,
issue: {
id: 'dpop:method-mismatch',
severity: 'error',
category: 'dpop',
title: 'DPoP method mismatch',
description: `DPoP proof htm="${payload['htm']}" does not match actual method "${data.method}".`,
steps: ['The htm claim must match the HTTP method of the request.'],
relevantData: { htm: payload['htm'] as string, method: data.method },
},
});
}

// htu mismatch
if (typeof payload['htu'] === 'string') {
const htu = payload['htu'] as string;
const urlNoQuery = data.url.split('?')[0];
if (htu !== urlNoQuery && htu !== data.url) {
candidates.push({
dedupKey: `dpop:uri-mismatch:${event.id}`,
eventId: event.id,
issue: {
id: 'dpop:uri-mismatch',
severity: 'error',
category: 'dpop',
title: 'DPoP URI mismatch',
description: 'The DPoP proof htu does not match the request URL.',
steps: [
'The htu claim must match the URL of the request (without query/fragment).',
],
relevantData: { htu, url: urlNoQuery },
},
});
}
}
}
}

// DPoP nonce required error
if (sem.dpop.nonce && data.status === 400) {
const body = data.responseBody as Record<string, unknown> | null;
if (body && body['error'] === 'use_dpop_nonce') {
candidates.push({
dedupKey: `dpop:nonce-required:${event.id}`,
eventId: event.id,
issue: {
id: 'dpop:nonce-required',
severity: 'info',
category: 'dpop',
title: 'DPoP nonce required',
description:
'The server requires a DPoP nonce. The client should retry with the provided nonce.',
steps: [
'Include the DPoP-Nonce header value in the next DPoP proof.',
'This is expected behavior for server nonce enforcement.',
],
relevantData: { nonce: sem.dpop.nonce },
},
});
}
}
}

// Check for token requests to DPoP servers missing DPoP header
const dpopServers = new Set<string>();
for (const event of events) {
if (event.oidcSemantics?.dpop?.tokenType?.toLowerCase() === 'dpop') {
if (event.data._tag === 'network') {
try {
dpopServers.add(new URL(event.data.url).origin);
} catch {
// ignore invalid URLs
}
}
}
}
for (const event of events) {
if (event.data._tag !== 'network') continue;
if (event.oidcSemantics?.oidcPhase !== 'token') continue;
if (event.data.requestHeaders['dpop']) continue;
try {
const origin = new URL(event.data.url).origin;
if (dpopServers.has(origin)) {
candidates.push({
dedupKey: `dpop:missing-proof:${event.id}`,
eventId: event.id,
issue: {
id: 'dpop:missing-proof',
severity: 'warning',
category: 'dpop',
title: 'Missing DPoP proof',
description:
'This token endpoint previously issued DPoP tokens but this request lacks a DPoP header.',
steps: ['Include a DPoP proof JWT in the DPoP header.'],
},
});
}
} catch {
// ignore
}
}

return candidates;
}
73 changes: 73 additions & 0 deletions packages/devtools-core/src/diagnosis/rules/flow-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AuthEvent } from '@wolfcola/devtools-types';
import type { IssueCandidate } from './types.js';

export function collectFlowConfigIssues(events: readonly AuthEvent[]): IssueCandidate[] {
const candidates: IssueCandidate[] = [];

for (const event of events) {
if (event.data._tag !== 'sdk') continue;
const { data } = event;
const { nodeStatus } = data;
const errorCode = data.error?.code ?? '';

if (nodeStatus === 'error' || nodeStatus === 'failure') {
const nodeName = data.nodeName ?? '';
candidates.push({
dedupKey: `flow:node-error:${event.id}`,
eventId: event.id,
issue: {
id: 'flow:node-error',
severity: 'error',
category: 'flow-config',
title: nodeName ? `Node error: ${nodeName}` : 'Node error',
description: `A DaVinci node returned status "${nodeStatus}".`,
steps: [
'Check connector configuration in DaVinci admin.',
'Review the error code in the SDK State tab.',
],
relevantData: nodeName ? { node: nodeName, status: nodeStatus } : { status: nodeStatus },
},
});
}

if (errorCode === 'CONNECTOR_ERROR') {
const httpStatus = data.error?.internalHttpStatus;
candidates.push({
dedupKey: `flow:connector-error:${event.id}`,
eventId: event.id,
issue: {
id: 'flow:connector-error',
severity: 'error',
category: 'flow-config',
title: httpStatus ? `Connector error (HTTP ${httpStatus})` : 'Connector error',
description: 'A DaVinci connector returned an HTTP error from its upstream endpoint.',
steps: [
'Verify connector credentials and endpoint URL in DaVinci admin.',
'Check the upstream service is reachable from your DaVinci environment.',
],
relevantData: httpStatus ? { 'internal-http-status': String(httpStatus) } : undefined,
},
});
}

if (errorCode === 'NOT_FOUND') {
candidates.push({
dedupKey: `flow:policy-not-found`,
eventId: event.id,
issue: {
id: 'flow:policy-not-found',
severity: 'error',
category: 'flow-config',
title: 'Flow policy not found',
description: 'The policy ID used to start this flow does not exist in the environment.',
steps: [
'Verify the policy ID (acr_values or flowId) matches your DaVinci environment.',
'Check that the policy is published and assigned to the correct application.',
],
},
});
}
}

return candidates;
}
15 changes: 15 additions & 0 deletions packages/devtools-core/src/diagnosis/rules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type {
FlowRule,
IssueCandidate,
Severity,
DiagnosisCategory,
FlowIssue,
EventIssue,
} from './types.js';
export { collectCorsIssues } from './cors.js';
export { collectTokenIssues } from './token.js';
export { collectFlowConfigIssues } from './flow-config.js';
export { collectOidcIssues } from './oidc.js';
export { collectOidcFlowIssues } from './oidc-flow.js';
export { collectDpopIssues } from './dpop.js';
export { collectParIssues } from './par.js';
Loading
Loading