Skip to content

Commit 64983d5

Browse files
committed
feat: auth flow router
1 parent 0623c5c commit 64983d5

6 files changed

Lines changed: 195 additions & 4 deletions

File tree

docs/authentication.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,26 @@ class ApiKeyScheme extends InlineAuthenticationScheme {
241241

242242
**Key Methods:**
243243
- `getAuthFlow()` - Define the authentication flow steps used to obtain a session
244+
- `getAuthRouter(basePath?)` - Generate a router that automatically exposes all auth steps as API endpoints
244245
- `getMiddleware()` - Middleware that checks the session on each request (inherited)
245246

247+
**Usage:**
248+
```typescript
249+
// Create your session authentication scheme
250+
const oauth2 = new OAuth2Scheme();
251+
252+
// Include in your main router
253+
class MainRouter extends BaseApiRouter {
254+
async routes() {
255+
return [
256+
oauth2.getAuthRouter('/auth'), // Auth endpoints
257+
SecureApiRouter, // Protected API (uses oauth2 as authentication)
258+
PublicRouter, // Public endpoints
259+
];
260+
}
261+
}
262+
```
263+
246264
**Use cases:**
247265
- OAuth2 with authorization code flow
248266
- SAML-based authentication

src/authentication/auth-flow.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuthStep } from './auth-step';
1+
import { AuthStepConstructor } from './auth-step';
22

33
/**
44
* A named collection of authentication steps
@@ -11,4 +11,4 @@ import { AuthStep } from './auth-step';
1111
* The flow represents the entire authentication process
1212
* Steps are executed in the order they're defined
1313
*/
14-
export type AuthFlow = Record<string, typeof AuthStep>;
14+
export type AuthFlow = Record<string, AuthStepConstructor>;

src/authentication/auth-step.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ import { BaseApiEndpoint } from '../router';
44
* Abstract base class for authentication flow steps
55
*/
66
export abstract class AuthStep extends BaseApiEndpoint {}
7+
8+
export type AuthStepConstructor = new (
9+
...params: ConstructorParameters<typeof AuthStep>
10+
) => AuthStep;

src/authentication/authentication-scheme.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import {
33
SecuritySchemeObject,
44
SecurityRequirementObject,
55
} from 'auto-oas/oas/v3.1';
6-
import { ApiNextFunction, ApiRequest, ApiResponse } from '../router';
6+
import {
7+
ApiNextFunction,
8+
ApiRequest,
9+
ApiResponse,
10+
BaseApiRouter,
11+
} from '../router';
712
import { SessionDriver } from '../session/session-driver';
813
import { AuthenticatedRequest } from './authenticated-request';
914
import { AuthFlow } from './auth-flow';
@@ -103,6 +108,26 @@ export abstract class SessionAuthenticationScheme extends AuthenticationScheme {
103108
*/
104109
public abstract getAuthFlow(): AuthFlow;
105110

111+
/**
112+
* Generate a router that exposes all auth steps as API endpoints
113+
* Each step in the AuthFlow becomes a route in the router
114+
*
115+
* @param basePath - Optional base path for the router (default: empty)
116+
* @returns Router with all auth steps as endpoints
117+
*/
118+
public getAuthRouter(basePath = ''): BaseApiRouter {
119+
const flow = this.getAuthFlow();
120+
const steps = Object.values(flow);
121+
122+
return new (class extends BaseApiRouter {
123+
override path = basePath;
124+
125+
async routes() {
126+
return steps;
127+
}
128+
})();
129+
}
130+
106131
public getMiddleware(): RequestHandler {
107132
return async (
108133
request: AuthenticatedRequest,

src/router/base.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,6 @@ export abstract class BaseApiRoute {
7575
}
7676
}
7777

78-
export type ApiRoute = { new (): BaseApiRoute };
78+
export type ApiRoute = new (
79+
...params: ConstructorParameters<typeof BaseApiRoute>
80+
) => BaseApiRoute;

test/spec/authentication/session-scheme.spec.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AuthFlow,
55
AuthStep,
66
} from '../../../src/authentication';
7+
import { BaseApiRouter } from '../../../src/router';
78
import { InMemorySessionDriver } from '../../../src/session';
89
import { SessionDriver } from '../../../src/session';
910
import { Session } from '../../../src/session/session';
@@ -471,4 +472,145 @@ describe('SessionAuthenticationScheme', () => {
471472
});
472473
});
473474
});
475+
476+
describe('getAuthRouter()', () => {
477+
it('should return a router instance', () => {
478+
const scheme = new TestSessionScheme();
479+
const router = scheme.getAuthRouter();
480+
481+
expect(router).toBeDefined();
482+
expect(router.path).toBe('');
483+
});
484+
485+
it('should set router path from basePath parameter', () => {
486+
const scheme = new TestSessionScheme();
487+
const router = scheme.getAuthRouter('/auth');
488+
489+
expect(router.path).toBe('/auth');
490+
});
491+
492+
it('should expose all auth steps as routes', () => {
493+
class ChallengeStep extends AuthStep {
494+
override path = '/challenge';
495+
async handle() {
496+
return { challenge: 'test' };
497+
}
498+
}
499+
500+
class AuthStep2 extends AuthStep {
501+
override path = '/authorize';
502+
async handle() {
503+
return { code: 'abc123' };
504+
}
505+
}
506+
507+
class TokenStep extends AuthStep {
508+
override path = '/token';
509+
async handle() {
510+
return { token: 'xyz789' };
511+
}
512+
}
513+
514+
class TestSchemeWithSteps extends SessionAuthenticationScheme {
515+
public readonly schemeName = 'TestScheme';
516+
public readonly type = 'oauth2' as const;
517+
518+
public getSecurityScheme(): SecuritySchemeObject {
519+
return {
520+
type: 'oauth2' as const,
521+
flows: {
522+
authorizationCode: {
523+
authorizationUrl: 'http://example.com/auth',
524+
tokenUrl: 'http://example.com/token',
525+
scopes: {},
526+
},
527+
},
528+
};
529+
}
530+
531+
public getAuthFlow(): AuthFlow {
532+
return {
533+
challenge: ChallengeStep,
534+
authorize: AuthStep2,
535+
token: TokenStep,
536+
};
537+
}
538+
}
539+
540+
const scheme = new TestSchemeWithSteps();
541+
const router = scheme.getAuthRouter('/oauth');
542+
543+
// Verify router has correct path and is a BaseApiRouter
544+
expect(router).toBeDefined();
545+
expect(router.path).toBe('/oauth');
546+
expect(router).toBeInstanceOf(BaseApiRouter);
547+
});
548+
549+
it('should work with empty basePath', () => {
550+
const scheme = new TestSessionScheme();
551+
const router = scheme.getAuthRouter();
552+
553+
expect(router.path).toBe('');
554+
});
555+
556+
it('should return different router instances on each call', () => {
557+
const scheme = new TestSessionScheme();
558+
const router1 = scheme.getAuthRouter('/auth');
559+
const router2 = scheme.getAuthRouter('/auth');
560+
561+
expect(router1).not.toBe(router2);
562+
});
563+
564+
it('should return auth steps from routes() method', async () => {
565+
class ChallengeStep extends AuthStep {
566+
override path = '/challenge';
567+
async handle() {
568+
return { challenge: 'test' };
569+
}
570+
}
571+
572+
class TokenStep extends AuthStep {
573+
override path = '/token';
574+
async handle() {
575+
return { token: 'xyz789' };
576+
}
577+
}
578+
579+
class TestSchemeWithRoutes extends SessionAuthenticationScheme {
580+
public readonly schemeName = 'TestScheme';
581+
public readonly type = 'oauth2' as const;
582+
583+
public getSecurityScheme(): SecuritySchemeObject {
584+
return {
585+
type: 'oauth2' as const,
586+
flows: {
587+
authorizationCode: {
588+
authorizationUrl: 'http://example.com/auth',
589+
tokenUrl: 'http://example.com/token',
590+
scopes: {},
591+
},
592+
},
593+
};
594+
}
595+
596+
public getAuthFlow(): AuthFlow {
597+
return {
598+
challenge: ChallengeStep,
599+
token: TokenStep,
600+
};
601+
}
602+
}
603+
604+
const scheme = new TestSchemeWithRoutes();
605+
const router = scheme.getAuthRouter('/auth');
606+
607+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
608+
const routes = await (router as any).routes();
609+
610+
expect(routes).toBeDefined();
611+
expect(routes.length).toBe(2);
612+
expect(routes[0]).toBe(ChallengeStep);
613+
expect(routes[1]).toBe(TokenStep);
614+
});
615+
});
474616
});

0 commit comments

Comments
 (0)