Skip to content
Open
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
13 changes: 10 additions & 3 deletions src/middlewares/openapi.security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface SecurityHandlerResult {
success: boolean;
status?: number;
error?: string;
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecurityHandlerResult.error is typed as string, but this code stores the caught exception object there and later reads e.error.message. Updating the type to something like unknown/any or a structured error shape would make the contract accurate and prevent consumers from assuming it’s a string.

Suggested change
error?: string;
error?: any;

Copilot uses AI. Check for mistakes.
attempted?: boolean; // true if credentials were provided and handler was called
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The attempted doc comment says “credentials were provided and handler was called”, but in executeHandlers it’s really derived from AuthValidator.validate() passing. Since validateOauth2 / validateOpenID are currently no-ops, those schemes will be marked as attempted even when no credentials are present. Either update the comment to match the current meaning, or refine the attempted detection so it truly reflects credential presence across all scheme types.

Suggested change
attempted?: boolean; // true if credentials were provided and handler was called
attempted?: boolean; // true if AuthValidator.validate() passed for this scheme and the handler was called

Copilot uses AI. Check for mistakes.
}

function extractErrorsFromResults(results: (SecurityHandlerResult | SecurityHandlerResult[])[]) {
Expand Down Expand Up @@ -87,7 +88,10 @@ export function security(
next();
} else {
const errors = extractErrorsFromResults(results);
throw errors[0];
// Prioritize errors where authentication was actually attempted
const attemptedErrors = errors.filter(e => e.attempted);
const errorToThrow = attemptedErrors.length > 0 ? attemptedErrors[0] : errors[0];
Comment on lines +91 to +93
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new attempted-error prioritization can mask configuration/5xx errors. For an OR security list where one scheme fails with a 5xx (e.g., missing/invalid handler or security scheme definition) and another scheme was attempted and failed with 401, this logic will now prefer the 401 and hide the 5xx. Consider prioritizing 5xx errors (or otherwise non-authn configuration errors) ahead of attempted-401s so misconfigurations still surface correctly.

Suggested change
// Prioritize errors where authentication was actually attempted
const attemptedErrors = errors.filter(e => e.attempted);
const errorToThrow = attemptedErrors.length > 0 ? attemptedErrors[0] : errors[0];
// Prefer server/configuration errors (5xx) so misconfigurations surface
const serverErrors = errors.filter(
(e) => typeof e.status === 'number' && e.status >= 500 && e.status < 600,
);
let errorToThrow;
if (serverErrors.length > 0) {
errorToThrow = serverErrors[0];
} else {
// Otherwise, prioritize errors where authentication was actually attempted
const attemptedErrors = errors.filter((e) => e.attempted);
errorToThrow = attemptedErrors.length > 0 ? attemptedErrors[0] : errors[0];
}

Copilot uses AI. Check for mistakes.
throw errorToThrow;
}
} catch (e) {
const message = e?.error?.message || 'unauthorized';
Expand Down Expand Up @@ -138,7 +142,7 @@ class SecuritySchemes {
}
return Promise.all(
Object.keys(s).map(async (securityKey) => {

let validatorPassed = false;
try {
const scheme = this.securitySchemes[securityKey];
const handler = this.securityHandlers?.[securityKey] ?? fallbackHandler;
Expand All @@ -157,6 +161,8 @@ class SecuritySchemes {
throw new InternalServerError({ message });
}
new AuthValidator(req, scheme, scopes).validate();
// If we reach here, validation passed and credentials were provided
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment states “validation passed and credentials were provided”, but AuthValidator.validate() currently does not validate oauth2/openIdConnect credentials at all (those methods are TODO/no-op). That means validatorPassed can become true even when no credentials are present for those scheme types. Consider rewording this comment (and/or renaming validatorPassed) to avoid implying stronger guarantees than the code provides.

Suggested change
// If we reach here, validation passed and credentials were provided
// If we reach here, AuthValidator did not report validation errors for this scheme

Copilot uses AI. Check for mistakes.
validatorPassed = true;
// expected handler results are:
// - throw exception,
// - return true,
Expand All @@ -167,7 +173,7 @@ class SecuritySchemes {
const securityScheme = <OpenAPIV3.SecuritySchemeObject>scheme;
const success = await handler(req, scopes, securityScheme);
if (success === true) {
return { success };
return { success, attempted: true };
} else {
throw Error();
}
Expand All @@ -176,6 +182,7 @@ class SecuritySchemes {
success: false,
status: e.status ?? 401,
error: e,
attempted: validatorPassed,
};
}
}),
Expand Down
11 changes: 11 additions & 0 deletions test/resources/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ paths:
description: OK
"401":
description: unauthorized

/bearer_or_apikey:
get:
security:
- BearerAuth: []
- ApiKeyAuth: []
responses:
"200":
description: OK
"401":
description: unauthorized

/no_security:
get:
Expand Down
25 changes: 25 additions & 0 deletions test/security.handlers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('security.handlers', () => {
.get(`/cookie_auth`, (req, res) => {res.json({ logged_in: true })})
.get(`/oauth2`, (req, res) => {res.json({ logged_in: true })})
.get(`/openid`, (req, res) => {res.json({ logged_in: true })})
.get(`/bearer_or_apikey`, (req, res) => {res.json({ logged_in: true })})
.get(`/api_key_or_anonymous`, (req, res) =>{
res.json({ logged_in: true })
})
Expand Down Expand Up @@ -412,6 +413,30 @@ describe('security.handlers', () => {
return request(app).get(`${basePath}/api_key_or_anonymous`).expect(200);
});

it('should return error from attempted security scheme, not first defined scheme', async () => {
// This test verifies the fix for the issue where when multiple security schemes
// are defined (e.g., BearerAuth OR ApiKeyAuth) and only one is attempted (ApiKeyAuth),
// the error from the attempted scheme should be returned, not the error from the
// first defined scheme (BearerAuth).
const validateSecurity = eovConf.validateSecurity as ValidateSecurityOpts;
(validateSecurity.handlers! as any).BearerAuth = (req, scopes, schema) => true;
(validateSecurity.handlers! as any).ApiKeyAuth = (req, scopes, schema) => {
throw new Error('Invalid API key provided');
};

return request(app)
.get(`${basePath}/bearer_or_apikey`)
.set('X-API-Key', 'wrong-key') // Provide API key but not Bearer token
.expect(401)
.then((r) => {
const body = r.body;
expect(body.errors).to.be.an('array');
expect(body.errors).to.have.length(1);
// Should return the API key error, not the Bearer token error
expect(body.errors[0].message).to.equals('Invalid API key provided');
});
});

it('should return 200 if api_key or anonymous and api key is supplied', async () => {
const validateSecurity = eovConf.validateSecurity as ValidateSecurityOpts;
(validateSecurity.handlers! as any).ApiKeyAuth = (req, scopes, schema) => true;
Expand Down