mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
feat(core): Add AuthStrategyRegistry for pluggable public API auth (no-changelog) (#27956)
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { AuthStrategyRegistry } from '../auth-strategy.registry';
|
||||
import type { AuthStrategy } from '../auth-strategy.types';
|
||||
|
||||
describe('AuthStrategyRegistry', () => {
|
||||
let registry: AuthStrategyRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AuthStrategyRegistry();
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('returns false when no strategies are registered', async () => {
|
||||
expect(await registry.authenticate(mock<AuthenticatedRequest>())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when all strategies abstain', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(null);
|
||||
strategy2.authenticate.mockResolvedValue(null);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
expect(await registry.authenticate(mock<AuthenticatedRequest>())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when the first strategy succeeds', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(true);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
expect(await registry.authenticate(mock<AuthenticatedRequest>())).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates strategies in registration order', async () => {
|
||||
const order: number[] = [];
|
||||
const strategy1: AuthStrategy = {
|
||||
authenticate: jest.fn().mockImplementation(async () => {
|
||||
order.push(1);
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
const strategy2: AuthStrategy = {
|
||||
authenticate: jest.fn().mockImplementation(async () => {
|
||||
order.push(2);
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
await registry.authenticate(mock<AuthenticatedRequest>());
|
||||
|
||||
expect(order).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('passes control to the next strategy when the current one abstains', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(null);
|
||||
strategy2.authenticate.mockResolvedValue(true);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
const req = mock<AuthenticatedRequest>();
|
||||
|
||||
expect(await registry.authenticate(req)).toBe(true);
|
||||
expect(strategy1.authenticate).toHaveBeenCalledWith(req);
|
||||
expect(strategy2.authenticate).toHaveBeenCalledWith(req);
|
||||
});
|
||||
|
||||
it('stops the chain immediately when a strategy returns false', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(false);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
expect(await registry.authenticate(mock<AuthenticatedRequest>())).toBe(false);
|
||||
expect(strategy2.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the chain after abstain then false, without calling further strategies', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
const strategy3 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(null);
|
||||
strategy2.authenticate.mockResolvedValue(false);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
registry.register(strategy3);
|
||||
|
||||
expect(await registry.authenticate(mock<AuthenticatedRequest>())).toBe(false);
|
||||
expect(strategy3.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call subsequent strategies after success', async () => {
|
||||
const strategy1 = mock<AuthStrategy>();
|
||||
const strategy2 = mock<AuthStrategy>();
|
||||
strategy1.authenticate.mockResolvedValue(true);
|
||||
|
||||
registry.register(strategy1);
|
||||
registry.register(strategy2);
|
||||
|
||||
await registry.authenticate(mock<AuthenticatedRequest>());
|
||||
|
||||
expect(strategy2.authenticate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the request object unchanged to each strategy', async () => {
|
||||
const strategy = mock<AuthStrategy>();
|
||||
strategy.authenticate.mockResolvedValue(null);
|
||||
registry.register(strategy);
|
||||
|
||||
const req = mock<AuthenticatedRequest>();
|
||||
await registry.authenticate(req);
|
||||
|
||||
expect(strategy.authenticate).toHaveBeenCalledWith(req);
|
||||
});
|
||||
|
||||
it('allows the same strategy instance to be registered multiple times', async () => {
|
||||
const strategy = mock<AuthStrategy>();
|
||||
strategy.authenticate.mockResolvedValue(true);
|
||||
|
||||
registry.register(strategy);
|
||||
registry.register(strategy);
|
||||
|
||||
await registry.authenticate(mock<AuthenticatedRequest>());
|
||||
|
||||
// First registration succeeded — short-circuited
|
||||
expect(strategy.authenticate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { AuthStrategy } from './auth-strategy.types';
|
||||
|
||||
/**
|
||||
* Ordered registry of AuthStrategy implementations for the public API.
|
||||
*
|
||||
* Each strategy returns:
|
||||
* null — abstain (try next strategy)
|
||||
* false — fail fast (stop chain, request is unauthenticated)
|
||||
* true — success (stop chain, request is authenticated)
|
||||
*
|
||||
* If all strategies abstain the request is treated as unauthenticated.
|
||||
*
|
||||
* Register a strategy (e.g. from a module init):
|
||||
* Container.get(AuthStrategyRegistry).register(myStrategy);
|
||||
*
|
||||
* Evaluate strategies (e.g. from auth middleware):
|
||||
* const authenticated = await registry.authenticate(req);
|
||||
* if (!authenticated) return false;
|
||||
*/
|
||||
@Service()
|
||||
export class AuthStrategyRegistry {
|
||||
private readonly strategies: AuthStrategy[] = [];
|
||||
|
||||
register(strategy: AuthStrategy): void {
|
||||
this.strategies.push(strategy);
|
||||
}
|
||||
|
||||
async authenticate(req: AuthenticatedRequest): Promise<boolean> {
|
||||
for (const strategy of this.strategies) {
|
||||
const result = await strategy.authenticate(req);
|
||||
if (result !== null) {
|
||||
return result; // true = success, false = fail fast
|
||||
}
|
||||
// null = this strategy abstained, continue to next
|
||||
}
|
||||
return false; // all strategies abstained = unauthenticated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
|
||||
/**
|
||||
* A single authentication strategy for the public API.
|
||||
*
|
||||
* Return values:
|
||||
* null — strategy does not apply to this request (e.g. no matching header);
|
||||
* the registry will try the next registered strategy.
|
||||
* false — strategy recognises this request but authentication failed
|
||||
* (e.g. expired key, disabled user); the registry stops immediately.
|
||||
* true — authentication succeeded; the strategy has set req.user
|
||||
* (and req.tokenGrant if applicable) before returning.
|
||||
*/
|
||||
export interface AuthStrategy {
|
||||
authenticate(req: AuthenticatedRequest): Promise<boolean | null>;
|
||||
}
|
||||
Reference in New Issue
Block a user