Skip to content

Commit a899bf9

Browse files
committed
feat: refactor auth system for stateful & multi-step schemes
1 parent e0ee528 commit a899bf9

10 files changed

Lines changed: 416 additions & 261 deletions

File tree

docs/authentication.md

Lines changed: 236 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ const server = new RestServer({
3636
});
3737
```
3838

39+
## Authentication Architecture
40+
41+
api-machine supports two main types of authentication schemes:
42+
43+
### Inline Authentication Schemes
44+
Run authentication inline **on every request**. Credentials are extracted and validated immediately:
45+
- Extract credentials from the request (e.g., Bearer tokens from Authorization header)
46+
- Validate credentials synchronously
47+
- Middleware runs for every incoming request
48+
- Examples: `BearerAuthenticationScheme`, API key schemes, JWT validation
49+
- Use for: Stateless authentication (tokens, API keys)
50+
51+
### Session Authentication Schemes
52+
Provide an `AuthFlow` to **obtain a session**, then verify that session **on every request**:
53+
- Define multi-step authentication process using `AuthFlow` (e.g., challenge → authorization → token exchange)
54+
- Session is obtained once through the auth flow
55+
- Session is then validated by checking the session ID on each subsequent request
56+
- Examples: OAuth2, SAML, session-based authentication
57+
- Use for: Stateful authentication flows requiring multiple steps
58+
3959
## Authentication Cascading
4060

4161
Authentication follows a priority hierarchy:
@@ -50,7 +70,7 @@ Authentication follows a priority hierarchy:
5070
```typescript
5171
class PublicRouter extends BaseApiRouter {
5272
override path = '/public';
53-
override authentication = null; // Bypass server auth
73+
override authentication = null;
5474

5575
async routes() {
5676
return [PublicEndpoint];
@@ -61,7 +81,7 @@ class AdminEndpoint extends BaseApiEndpoint {
6181
override path = '/admin';
6282
override authentication = new BearerAuthenticationScheme({
6383
checkToken: async (token) => token === 'admin-token',
64-
}); // Override router auth
84+
});
6585

6686
async handle() {
6787
return { admin: true };
@@ -73,7 +93,7 @@ class AdminEndpoint extends BaseApiEndpoint {
7393

7494
### Bearer Authentication
7595

76-
Validates Bearer tokens from the `Authorization` header.
96+
Validates Bearer tokens from the `Authorization` header on **every request**. Uses `InlineAuthenticationScheme` for synchronous inline token validation.
7797

7898
```typescript
7999
import { BearerAuthenticationScheme } from 'api-machine';
@@ -89,12 +109,18 @@ const auth = new BearerAuthenticationScheme({
89109
});
90110
```
91111

92-
**Features:**
93-
- Automatically validates `Authorization: Bearer <token>` header format
112+
**How it works:**
113+
- Middleware runs for every incoming request
114+
- Extracts `Authorization: Bearer <token>` header
115+
- Validates token using `checkToken()`
94116
- Returns 401 Unauthorized if token missing or invalid
95-
- Integrates with OpenAPI security schemes
96117
- Sets `request.authenticated = true` on successful validation
97118

119+
**Architecture:**
120+
- Extends `InlineAuthenticationScheme` for stateless credential-based auth
121+
- `getCredentials()` - Extracts Bearer token from Authorization header on each request
122+
- `authenticate()` - Validates extracted token using `checkToken()` on each request
123+
98124
## Server-Level Authentication
99125

100126
Apply authentication to all endpoints by default:
@@ -145,6 +171,143 @@ class AdminEndpoint extends BaseApiEndpoint {
145171
}
146172
```
147173

174+
## Inline Authentication Schemes
175+
176+
`InlineAuthenticationScheme` runs authentication inline **on every request**. Credentials are extracted from the request and validated immediately, without requiring a separate session object.
177+
178+
**How it works:**
179+
1. Middleware runs for each incoming request
180+
2. `getCredentials(request)` extracts credentials from the request
181+
3. `authenticate({ credentials, request })` validates the credentials
182+
4. If validation succeeds, request continues; otherwise an error is thrown
183+
5. No session is created or stored
184+
185+
**Key Methods:**
186+
- `getCredentials(request)` - Extract credentials from the request
187+
- `authenticate({ credentials, request })` - Validate the extracted credentials
188+
189+
**Use cases:**
190+
- API key validation (fixed key in header)
191+
- JWT token validation (stateless bearer tokens)
192+
- Basic authentication (username/password in header)
193+
- Any credential that can be validated synchronously on each request
194+
195+
```typescript
196+
import { InlineAuthenticationScheme } from 'api-machine';
197+
import { ApiRequest } from 'api-machine';
198+
199+
class ApiKeyScheme extends InlineAuthenticationScheme {
200+
constructor(private apiKey: string) {
201+
super();
202+
}
203+
204+
getSecurityScheme() {
205+
return {
206+
type: 'apiKey' as const,
207+
in: 'header' as const,
208+
name: 'X-API-Key',
209+
};
210+
}
211+
212+
getCredentials(request: ApiRequest): unknown {
213+
const key = request.headers['x-api-key'];
214+
if (!key) {
215+
throw new UnauthorizedError('API key is required');
216+
}
217+
return key;
218+
}
219+
220+
async authenticate(options: {
221+
credentials: string;
222+
request: ApiRequest;
223+
}): Promise<void> {
224+
if (options.credentials !== this.apiKey) {
225+
throw new UnauthorizedError('Invalid API key');
226+
}
227+
}
228+
}
229+
```
230+
231+
## Session Authentication Schemes
232+
233+
`SessionAuthenticationScheme` manages stateful authentication. It provides an `AuthFlow` to **obtain a session**, and then verifies that session **on each request**.
234+
235+
**How it works:**
236+
1. `getAuthFlow()` defines the multi-step authentication process (e.g., challenge → authorization → token exchange)
237+
2. User goes through the auth flow to obtain a session
238+
3. Session ID is stored (typically in cookies or request state)
239+
4. On each subsequent request, middleware calls `checkSession()` to verify the session exists and is valid
240+
5. Only after session is verified, the request continues
241+
242+
**Key Methods:**
243+
- `getAuthFlow()` - Define the authentication flow steps used to obtain a session
244+
- `getMiddleware()` - Middleware that checks the session on each request (inherited)
245+
246+
**Use cases:**
247+
- OAuth2 with authorization code flow
248+
- SAML-based authentication
249+
- Multi-step authentication requiring user interaction
250+
- Session-based systems where session state must be maintained
251+
252+
```typescript
253+
import { SessionAuthenticationScheme, AuthFlow, AuthStep } from 'api-machine';
254+
import { ApiRequest, ApiResponse } from 'api-machine';
255+
256+
class OAuth2Scheme extends SessionAuthenticationScheme {
257+
getSecurityScheme() {
258+
return {
259+
type: 'oauth2' as const,
260+
flows: {
261+
authorizationCode: {
262+
authorizationUrl: 'https://provider.com/oauth/authorize',
263+
tokenUrl: 'https://provider.com/oauth/token',
264+
scopes: { 'read:data': 'Read data' },
265+
},
266+
},
267+
};
268+
}
269+
270+
getAuthFlow(): AuthFlow {
271+
return {
272+
challenge: {
273+
description: 'Generate authorization challenge (PKCE)',
274+
async handle(request: ApiRequest, response: ApiResponse) {
275+
const challenge = generateChallenge();
276+
return { challenge };
277+
},
278+
},
279+
authorization: {
280+
description: 'User authorizes application at OAuth2 provider',
281+
async handle(request: ApiRequest, response: ApiResponse) {
282+
// User is redirected to provider, returns with authorization code
283+
const code = request.query.code as string;
284+
return { code };
285+
},
286+
},
287+
tokenExchange: {
288+
description: 'Exchange authorization code for access token and session',
289+
async handle(request: ApiRequest, response: ApiResponse) {
290+
const accessToken = await exchangeCodeForToken(request.body.code);
291+
const session = await createSession(accessToken);
292+
return { session };
293+
},
294+
},
295+
};
296+
}
297+
}
298+
```
299+
300+
**Session Validation Flow:**
301+
After a session is obtained through the auth flow, each request:
302+
1. Extracts the session ID (from cookies, headers, etc.)
303+
2. Calls `checkSession()` to verify the session still exists
304+
3. Validates the session is not expired
305+
4. Sets `request.authenticated = true` and allows request to continue
306+
307+
## AuthFlow & AuthStep
308+
309+
An `AuthFlow` is a named collection of `AuthStep` objects representing the complete multi-step authentication process used to obtain a session. See [src/authentication/auth-flow.ts](../src/authentication/auth-flow.ts) and [src/authentication/auth-step.ts](../src/authentication/auth-step.ts) for type definitions.
310+
148311
## Public Routes
149312

150313
Make routes explicitly public by setting `authentication = null`:
@@ -164,63 +327,104 @@ This bypasses any parent router or server authentication.
164327

165328
## Custom Authentication Schemes
166329

167-
Create custom authentication by extending `AuthenticationScheme`:
330+
Create custom authentication by extending `InlineAuthenticationScheme` or `SessionAuthenticationScheme` depending on your needs.
331+
332+
**For credential-based auth** (API keys, tokens):
168333

169334
```typescript
170-
import { AuthenticationScheme } from 'api-machine';
171-
import { RequestHandler } from 'express';
335+
import { InlineAuthenticationScheme } from 'api-machine';
336+
import { ApiRequest } from 'api-machine';
172337

173-
class ApiKeyAuthenticationScheme extends AuthenticationScheme {
174-
constructor(private apiKey: string) {
175-
super();
176-
}
177-
338+
class CustomKeyScheme extends InlineAuthenticationScheme {
178339
getSecurityScheme() {
179340
return {
180341
type: 'apiKey' as const,
181342
in: 'header' as const,
182-
name: 'X-API-Key',
343+
name: 'X-Custom-Key',
183344
};
184345
}
185346

186-
getMiddleware(): RequestHandler {
187-
return async (req, res, next) => {
188-
const apiKey = req.headers['x-api-key'];
189-
190-
if (apiKey !== this.apiKey) {
191-
throw new UnauthorizedError('Invalid API key');
192-
}
193-
194-
next();
195-
};
347+
getCredentials(request: ApiRequest): unknown {
348+
return request.headers['x-custom-key'];
349+
}
350+
351+
async authenticate(options: {
352+
credentials: string;
353+
request: ApiRequest;
354+
}): Promise<void> {
355+
if (!await this.validateKey(options.credentials)) {
356+
throw new UnauthorizedError('Invalid key');
357+
}
358+
}
359+
360+
private async validateKey(key: string): Promise<boolean> {
361+
// Your validation logic
362+
return key === 'valid-key';
196363
}
197364
}
198365
```
199366

200367
**Required Methods:**
201368
- `getSecurityScheme()`: Returns OpenAPI SecuritySchemeObject
202-
- `getMiddleware()`: Returns Express middleware function
369+
- `getMiddleware()`: Returns Express middleware function (inherited)
203370
- `getSecurityRequirement()`: Optional, returns OpenAPI SecurityRequirementObject
204371

372+
## Available Exports
373+
374+
The authentication module exports the following. See [src/authentication/index.ts](../src/authentication/index.ts) for the complete list:
375+
376+
- `AuthenticationScheme` - Base class for all schemes
377+
- `InlineAuthenticationScheme` - For credential-based auth
378+
- `SessionAuthenticationScheme` - For stateful multi-step auth
379+
- `AuthStep` - Interface for auth flow steps
380+
- `AuthFlow` - Type for collections of steps
381+
- `BearerAuthenticationScheme` - Built-in Bearer token scheme
382+
- `AuthenticatedRequest` - Request type with auth flag
383+
205384
## OpenAPI Integration
206385

207386
Authentication schemes automatically generate OpenAPI security definitions:
208387

209388
```typescript
210-
const auth = new BearerAuthenticationScheme({
389+
const bearerAuth = new BearerAuthenticationScheme({
211390
checkToken: async (token) => await validate(token),
212391
schemeName: 'BearerAuth',
213392
description: 'JWT Bearer Authentication',
214393
});
394+
395+
const oauth2Auth = new OAuth2Scheme();
396+
// Automatically generates OAuth2 security scheme in OpenAPI spec
215397
```
216398

217399
## Best Practices
218400

219-
1. **Use server-level auth as default** - Apply authentication at the server level and override only where needed
220-
2. **Make public routes explicit** - Use `authentication = null` to clearly mark public endpoints
221-
3. **Validate tokens properly** - Always validate tokens against a secure source (database, JWT verification, etc.)
222-
4. **Use descriptive scheme names** - Help API consumers understand authentication requirements
223-
5. **Leverage cascading** - Set auth at the appropriate level (server/router/endpoint) based on your needs
401+
1. **Choose the right scheme type:**
402+
- Use `InlineAuthenticationScheme` for **stateless** auth that happens on every request (API keys, JWT tokens, bearer tokens)
403+
- Use `SessionAuthenticationScheme` for **stateful** auth with multi-step flows that obtain a session once, then check it on each request (OAuth2, SAML, login forms)
404+
405+
2. **Inline scheme design:**
406+
- Keep `getCredentials()` focused on extraction only
407+
- Keep `authenticate()` focused on validation only
408+
- Runs on every request, so keep it fast
409+
410+
3. **Session scheme design:**
411+
- Define clear, distinct steps in `AuthFlow` (challenge → authorization → tokenExchange)
412+
- Session is obtained once, then validated on subsequent requests
413+
- Use efficient session lookup/validation to minimize request overhead
414+
415+
4. **Use server-level auth as default** - Apply authentication at the server level and override only where needed
416+
417+
5. **Make public routes explicit** - Use `authentication = null` to clearly mark public endpoints
418+
419+
6. **Validate thoroughly:**
420+
- For inline: validate credentials against secure sources (database, JWT verification)
421+
- For session: verify session exists and hasn't expired
422+
423+
7. **Use descriptive names** - Help API consumers understand your authentication approach
424+
- Inline: use scheme names like "BearerAuth", "ApiKeyAuth"
425+
- Session: use clear auth flow step names (e.g., 'challenge', 'authorization', 'tokenExchange')
426+
427+
8. **Leverage cascading** - Set auth at the appropriate level (server/router/endpoint) based on your needs
224428

225429
## Examples
226430

src/authentication/auth-flow.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { AuthStep } from './auth-step';
2+
3+
/**
4+
* A named collection of authentication steps
5+
* Each step is identified by a key
6+
* (e.g., 'challenge', 'authorization', 'tokenExchange')
7+
*
8+
* The flow represents the entire authentication process
9+
* Steps are executed in the order they're defined
10+
*/
11+
export type AuthFlow = Record<string, AuthStep>;

src/authentication/auth-step.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ApiRequest, ApiResponse } from '../router';
2+
3+
/**
4+
* Represents a single step in an authentication flow
5+
* Can be used for inline schemes (as middleware)
6+
* or session schemes (as endpoint)
7+
*/
8+
export interface AuthStep {
9+
/**
10+
* Human-readable description
11+
*/
12+
description?: string;
13+
14+
/**
15+
* Execute this authentication step
16+
* Called when processing requests for this step
17+
*
18+
* @param request - The incoming request
19+
* @returns Result indicating success/failure
20+
* @throws HTTPError for authentication failures
21+
*/
22+
handle(
23+
request: ApiRequest,
24+
response: ApiResponse
25+
): Promise<Record<string, unknown>>;
26+
}

0 commit comments

Comments
 (0)