@travetto/auth-web 6.0.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,292 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/auth-web/DOC.tsx and execute "npx trv doc" to rebuild -->
3
+ # Web Auth
4
+
5
+ ## Web authentication integration support for the Travetto framework
6
+
7
+ **Install: @travetto/auth-web**
8
+ ```bash
9
+ npm install @travetto/auth-web
10
+
11
+ # or
12
+
13
+ yarn add @travetto/auth-web
14
+ ```
15
+
16
+ This is a primary integration for the [Authentication](https://github.com/travetto/travetto/tree/main/module/auth#readme "Authentication scaffolding for the Travetto framework") module with the [Web API](https://github.com/travetto/travetto/tree/main/module/web#readme "Declarative api for Web Applications with support for the dependency injection.") module.
17
+
18
+ The integration with the [Web API](https://github.com/travetto/travetto/tree/main/module/web#readme "Declarative api for Web Applications with support for the dependency injection.") module touches multiple levels. Primarily:
19
+ * Authenticating
20
+ * Maintaining Auth Context
21
+ * Endpoint Decoration
22
+ * Multi-Step Login
23
+
24
+ ## Authenticating
25
+ Every external framework integration relies upon the [Authenticator](https://github.com/travetto/travetto/tree/main/module/auth/src/types/authenticator.ts#L9) contract. This contract defines the boundaries between both frameworks and what is needed to pass between. As stated elsewhere, the goal is to be as flexible as possible, and so the contract is as minimal as possible:
26
+
27
+ **Code: Structure for the Identity Source**
28
+ ```typescript
29
+ import { AnyMap } from '@travetto/runtime';
30
+ import { Principal } from './principal.ts';
31
+
32
+ /**
33
+ * Represents the general shape of additional login context, usually across multiple calls
34
+ *
35
+ * @concrete
36
+ */
37
+ export interface AuthenticatorState extends AnyMap { }
38
+
39
+ /**
40
+ * Supports validation payload of type T into an authenticated principal
41
+ *
42
+ * @concrete
43
+ */
44
+ export interface Authenticator<T = unknown, C = unknown, P extends Principal = Principal> {
45
+ /**
46
+ * Retrieve the authenticator state for the given request
47
+ */
48
+ getState?(context?: C): Promise<AuthenticatorState | undefined> | AuthenticatorState | undefined;
49
+
50
+ /**
51
+ * Verify the payload, ensuring the payload is correctly identified.
52
+ *
53
+ * @returns Valid principal if authenticated
54
+ * @returns undefined if authentication is valid, but incomplete (multi-step)
55
+ * @throws AppError if authentication fails
56
+ */
57
+ authenticate(payload: T, context?: C): Promise<P | undefined> | P | undefined;
58
+ }
59
+ ```
60
+
61
+ The only required method to be defined is the `authenticate` method. This takes in a pre-principal payload and a filter context with a [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11), and is responsible for:
62
+ * Returning an [Principal](https://github.com/travetto/travetto/tree/main/module/auth/src/types/principal.ts#L7) if authentication was successful
63
+ * Throwing an error if it failed
64
+ * Returning undefined if the authentication is multi-staged and has not completed yet
65
+ A sample auth provider would look like:
66
+
67
+ **Code: Sample Identity Source**
68
+ ```typescript
69
+ import { AuthenticationError, Authenticator } from '@travetto/auth';
70
+
71
+ type User = { username: string, password: string };
72
+
73
+ export class SimpleAuthenticator implements Authenticator<User> {
74
+ async authenticate({ username, password }: User) {
75
+ if (username === 'test' && password === 'test') {
76
+ return {
77
+ id: 'test',
78
+ source: 'simple',
79
+ permissions: [],
80
+ details: {
81
+ username: 'test'
82
+ }
83
+ };
84
+ } else {
85
+ throw new AuthenticationError('Invalid credentials');
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ The provider must be registered with a custom symbol to be used within the framework. At startup, all registered [Authenticator](https://github.com/travetto/travetto/tree/main/module/auth/src/types/authenticator.ts#L9)'s are collected and stored for reference at runtime, via symbol. For example:
92
+
93
+ **Code: Potential Facebook provider**
94
+ ```typescript
95
+ import { InjectableFactory } from '@travetto/di';
96
+
97
+ import { SimpleAuthenticator } from './source.ts';
98
+
99
+ export const FbAuthSymbol = Symbol.for('auth-facebook');
100
+
101
+ export class AppConfig {
102
+ @InjectableFactory(FbAuthSymbol)
103
+ static facebookIdentity() {
104
+ return new SimpleAuthenticator();
105
+ }
106
+ }
107
+ ```
108
+
109
+ The symbol `FB_AUTH` is what will be used to reference providers at runtime. This was chosen, over `class` references due to the fact that most providers will not be defined via a new class, but via an [@InjectableFactory](https://github.com/travetto/travetto/tree/main/module/di/src/decorator.ts#L70) method.
110
+
111
+ ## Maintaining Auth Context
112
+ The [AuthContextInterceptor](https://github.com/travetto/travetto/tree/main/module/auth-web/src/interceptors/context.ts#L19) acts as the bridge between the [Authentication](https://github.com/travetto/travetto/tree/main/module/auth#readme "Authentication scaffolding for the Travetto framework") and [Web API](https://github.com/travetto/travetto/tree/main/module/web#readme "Declarative api for Web Applications with support for the dependency injection.") modules. It serves to take an authenticated principal (via the [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11)/[WebResponse](https://github.com/travetto/travetto/tree/main/module/web/src/types/response.ts#L3)) and integrate it into the [AuthContext](https://github.com/travetto/travetto/tree/main/module/auth/src/context.ts#L14). Leveraging [WebAuthConfig](https://github.com/travetto/travetto/tree/main/module/auth-web/src/config.ts#L8)'s configuration allows for basic control of how the principal is encoded and decoded, primarily with the choice between using a header or a cookie, and which header, or cookie value is specifically referenced. Additionally, the encoding process allows for auto-renewing of the token (on by default). The information is encoded into the [JWT](https://jwt.io/) appropriately, and when encoding using cookies, is also set as the expiry time for the cookie.
113
+
114
+ **Note for Cookie Use:** The automatic renewal, update, seamless receipt and transmission of the [Principal](https://github.com/travetto/travetto/tree/main/module/auth/src/types/principal.ts#L7) cookie act as a light-weight session. Generally the goal is to keep the token as small as possible, but for small amounts of data, this pattern proves to be fairly sufficient at maintaining a decentralized state.
115
+
116
+ The [PrincipalCodec](https://github.com/travetto/travetto/tree/main/module/auth-web/src/types.ts#L10) contract is the primary interface for reading and writing [Principal](https://github.com/travetto/travetto/tree/main/module/auth/src/types/principal.ts#L7) data out of the [WebRequest](https://github.com/travetto/travetto/tree/main/module/web/src/types/request.ts#L11). This contract is flexible by design, allowing for all sorts of usage. [JWTPrincipalCodec](https://github.com/travetto/travetto/tree/main/module/auth-web/src/codec.ts#L15) is the default [PrincipalCodec](https://github.com/travetto/travetto/tree/main/module/auth-web/src/types.ts#L10), leveraging [JWT](https://jwt.io/)s for encoding/decoding the principal information.
117
+
118
+ **Code: JWTPrincipalCodec**
119
+ ```typescript
120
+ import { createVerifier, create, Jwt, Verifier, SupportedAlgorithms } from 'njwt';
121
+
122
+ import { AuthContext, AuthenticationError, AuthToken, Principal } from '@travetto/auth';
123
+ import { Injectable, Inject } from '@travetto/di';
124
+ import { WebResponse, WebRequest, WebAsyncContext, CookieJar } from '@travetto/web';
125
+ import { AppError, castTo, TimeUtil } from '@travetto/runtime';
126
+
127
+ import { CommonPrincipalCodecSymbol, PrincipalCodec } from './types.ts';
128
+ import { WebAuthConfig } from './config.ts';
129
+
130
+ /**
131
+ * JWT Principal codec
132
+ */
133
+ @Injectable(CommonPrincipalCodecSymbol)
134
+ export class JWTPrincipalCodec implements PrincipalCodec {
135
+
136
+ @Inject()
137
+ config: WebAuthConfig;
138
+
139
+ @Inject()
140
+ authContext: AuthContext;
141
+
142
+ @Inject()
143
+ webAsyncContext: WebAsyncContext;
144
+
145
+ #verifier: Verifier;
146
+ #algorithm: SupportedAlgorithms = 'HS256';
147
+
148
+ postConstruct(): void {
149
+ this.#verifier = createVerifier()
150
+ .setSigningAlgorithm(this.#algorithm)
151
+ .withKeyResolver((kid, cb) => {
152
+ const rec = this.config.keyMap[kid];
153
+ return cb(rec ? null : new AuthenticationError('Invalid'), rec.key);
154
+ });
155
+ }
156
+
157
+ async verify(token: string): Promise<Principal> {
158
+ try {
159
+ const jwt: Jwt & { body: { core: Principal } } = await new Promise((res, rej) =>
160
+ this.#verifier.verify(token, (err, v) => err ? rej(err) : res(castTo(v)))
161
+ );
162
+ return jwt.body.core;
163
+ } catch (err) {
164
+ if (err instanceof Error && err.name.startsWith('Jwt')) {
165
+ throw new AuthenticationError(err.message, { category: 'permissions' });
166
+ }
167
+ throw err;
168
+ }
169
+ }
170
+
171
+ token(request: WebRequest): AuthToken | undefined {
172
+ const value = (this.config.mode === 'header') ?
173
+ request.headers.getWithPrefix(this.config.header, this.config.headerPrefix) :
174
+ this.webAsyncContext.getValue(CookieJar).get(this.config.cookie, { signed: false });
175
+ return value ? { type: 'jwt', value } : undefined;
176
+ }
177
+
178
+ async decode(request: WebRequest): Promise<Principal | undefined> {
179
+ const token = this.token(request);
180
+ return token ? await this.verify(token.value) : undefined;
181
+ }
182
+
183
+ async create(value: Principal, keyId: string = 'default'): Promise<string> {
184
+ const keyRec = this.config.keyMap[keyId];
185
+ if (!keyRec) {
186
+ throw new AppError('Requested unknown key for signing');
187
+ }
188
+ const jwt = create({}, '-')
189
+ .setExpiration(value.expiresAt!)
190
+ .setIssuedAt(TimeUtil.asSeconds(value.issuedAt!))
191
+ .setClaim('core', castTo({ ...value }))
192
+ .setIssuer(value.issuer!)
193
+ .setJti(value.sessionId!)
194
+ .setSubject(value.id)
195
+ .setHeader('kid', keyRec.id)
196
+ .setSigningKey(keyRec.key)
197
+ .setSigningAlgorithm(this.#algorithm);
198
+ return jwt.toString();
199
+ }
200
+
201
+ async encode(response: WebResponse, data: Principal | undefined): Promise<WebResponse> {
202
+ const token = data ? await this.create(data) : undefined;
203
+ const { header, headerPrefix, cookie } = this.config;
204
+ if (this.config.mode === 'header') {
205
+ response.headers.setWithPrefix(header, token, headerPrefix);
206
+ } else {
207
+ this.webAsyncContext.getValue(CookieJar).set({ name: cookie, value: token, signed: false, expires: data?.expiresAt });
208
+ }
209
+ return response;
210
+ }
211
+ }
212
+ ```
213
+
214
+ As you can see, the encode token just creates a [JWT](https://jwt.io/) based on the principal provided, and decoding verifies the token, and returns the principal.
215
+
216
+ A trivial/sample custom [PrincipalCodec](https://github.com/travetto/travetto/tree/main/module/auth-web/src/types.ts#L10) can be seen here:
217
+
218
+ **Code: Custom Principal Codec**
219
+ ```typescript
220
+ import { Principal } from '@travetto/auth';
221
+ import { PrincipalCodec } from '@travetto/auth-web';
222
+ import { Injectable } from '@travetto/di';
223
+ import { BinaryUtil } from '@travetto/runtime';
224
+ import { WebResponse, WebRequest } from '@travetto/web';
225
+
226
+ @Injectable()
227
+ export class CustomCodec implements PrincipalCodec {
228
+ secret: string;
229
+
230
+ decode(request: WebRequest): Promise<Principal | undefined> | Principal | undefined {
231
+ const [userId, sig] = request.headers.get('USER_ID')?.split(':') ?? [];
232
+ if (userId && sig === BinaryUtil.hash(userId + this.secret)) {
233
+ let p: Principal | undefined;
234
+ // Lookup user from db, remote system, etc.,
235
+ return p;
236
+ }
237
+ return;
238
+ }
239
+ encode(response: WebResponse, data: Principal | undefined): WebResponse {
240
+ if (data) {
241
+ response.headers.set('USER_ID', `${data.id}:${BinaryUtil.hash(data.id + this.secret)}`);
242
+ }
243
+ return response;
244
+ }
245
+ }
246
+ ```
247
+
248
+ This implementation is not suitable for production, but shows the general pattern needed to integrate with any principal source.
249
+
250
+ ## Endpoint Decoration
251
+ [@Login](https://github.com/travetto/travetto/tree/main/module/auth-web/src/decorator.ts#L13) integrates with middleware that will authenticate the user as defined by the specified providers, or throw an error if authentication is unsuccessful.
252
+
253
+ [@Logout](https://github.com/travetto/travetto/tree/main/module/auth-web/src/decorator.ts#L48) integrates with middleware that will automatically deauthenticate a user, throw an error if the user is unauthenticated.
254
+
255
+ **Code: Using provider with endpoints**
256
+ ```typescript
257
+ import { Controller, Get, ContextParam, WebResponse } from '@travetto/web';
258
+ import { Login, Authenticated, Logout } from '@travetto/auth-web';
259
+ import { Principal } from '@travetto/auth';
260
+
261
+ import { FbAuthSymbol } from './facebook.ts';
262
+
263
+ @Controller('/auth')
264
+ export class SampleAuth {
265
+
266
+ @ContextParam()
267
+ user: Principal;
268
+
269
+ @Get('/simple')
270
+ @Login(FbAuthSymbol)
271
+ async simpleLogin() {
272
+ return WebResponse.redirect('/auth/self');
273
+ }
274
+
275
+ @Get('/self')
276
+ @Authenticated()
277
+ async getSelf() {
278
+ return this.user;
279
+ }
280
+
281
+ @Get('/logout')
282
+ @Logout()
283
+ async logout() {
284
+ return WebResponse.redirect('/auth/self');
285
+ }
286
+ }
287
+ ```
288
+
289
+ [@Authenticated](https://github.com/travetto/travetto/tree/main/module/auth-web/src/decorator.ts#L25) and [@Unauthenticated](https://github.com/travetto/travetto/tree/main/module/auth-web/src/decorator.ts#L37) will simply enforce whether or not a user is logged in and throw the appropriate error messages as needed. Additionally, the [Principal](https://github.com/travetto/travetto/tree/main/module/auth/src/types/principal.ts#L7) is accessible as a resource that can be exposed as a [@ContextParam](https://github.com/travetto/travetto/tree/main/module/web/src/decorator/param.ts#L61) on an [@Injectable](https://github.com/travetto/travetto/tree/main/module/di/src/decorator.ts#L29) class.
290
+
291
+ ## Multi-Step Login
292
+ When authenticating, with a multi-step process, it is useful to share information between steps. The `authenticatorState` of [AuthContext](https://github.com/travetto/travetto/tree/main/module/auth/src/context.ts#L14) field is intended to be a location in which that information is persisted. Currently only [passport](http://passportjs.org) support is included, when dealing with multi-step logins. This information can also be injected into a web endpoint method, using the [AuthenticatorState](https://github.com/travetto/travetto/tree/main/module/auth/src/types/authenticator.ts#L9) type;
package/__index__.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './src/interceptors/context.ts';
2
+ export * from './src/interceptors/login.ts';
3
+ export * from './src/interceptors/logout.ts';
4
+ export * from './src/interceptors/verify.ts';
5
+ export * from './src/codec.ts';
6
+ export * from './src/config.ts';
7
+ export * from './src/decorator.ts';
8
+ export * from './src/types.ts';
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@travetto/auth-web",
3
+ "version": "6.0.0-rc.4",
4
+ "description": "Web authentication integration support for the Travetto framework",
5
+ "keywords": [
6
+ "authentication",
7
+ "web",
8
+ "travetto",
9
+ "decorators",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://travetto.io",
13
+ "license": "MIT",
14
+ "author": {
15
+ "email": "travetto.framework@gmail.com",
16
+ "name": "Travetto Framework"
17
+ },
18
+ "files": [
19
+ "__index__.ts",
20
+ "src",
21
+ "support"
22
+ ],
23
+ "main": "__index__.ts",
24
+ "repository": {
25
+ "url": "git+https://github.com/travetto/travetto.git",
26
+ "directory": "module/auth-web"
27
+ },
28
+ "dependencies": {
29
+ "@travetto/auth": "^6.0.0-rc.2",
30
+ "@travetto/config": "^6.0.0-rc.2",
31
+ "@travetto/web": "^6.0.0-rc.2",
32
+ "njwt": "^2.0.1"
33
+ },
34
+ "peerDependencies": {
35
+ "@travetto/test": "^6.0.0-rc.2"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@travetto/test": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "travetto": {
43
+ "displayName": "Web Auth"
44
+ },
45
+ "private": false,
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { createVerifier, create, Jwt, Verifier, SupportedAlgorithms } from 'njwt';
2
+
3
+ import { AuthContext, AuthenticationError, AuthToken, Principal } from '@travetto/auth';
4
+ import { Injectable, Inject } from '@travetto/di';
5
+ import { WebResponse, WebRequest, WebAsyncContext, CookieJar } from '@travetto/web';
6
+ import { AppError, castTo, TimeUtil } from '@travetto/runtime';
7
+
8
+ import { CommonPrincipalCodecSymbol, PrincipalCodec } from './types.ts';
9
+ import { WebAuthConfig } from './config.ts';
10
+
11
+ /**
12
+ * JWT Principal codec
13
+ */
14
+ @Injectable(CommonPrincipalCodecSymbol)
15
+ export class JWTPrincipalCodec implements PrincipalCodec {
16
+
17
+ @Inject()
18
+ config: WebAuthConfig;
19
+
20
+ @Inject()
21
+ authContext: AuthContext;
22
+
23
+ @Inject()
24
+ webAsyncContext: WebAsyncContext;
25
+
26
+ #verifier: Verifier;
27
+ #algorithm: SupportedAlgorithms = 'HS256';
28
+
29
+ postConstruct(): void {
30
+ this.#verifier = createVerifier()
31
+ .setSigningAlgorithm(this.#algorithm)
32
+ .withKeyResolver((kid, cb) => {
33
+ const rec = this.config.keyMap[kid];
34
+ return cb(rec ? null : new AuthenticationError('Invalid'), rec.key);
35
+ });
36
+ }
37
+
38
+ async verify(token: string): Promise<Principal> {
39
+ try {
40
+ const jwt: Jwt & { body: { core: Principal } } = await new Promise((res, rej) =>
41
+ this.#verifier.verify(token, (err, v) => err ? rej(err) : res(castTo(v)))
42
+ );
43
+ return jwt.body.core;
44
+ } catch (err) {
45
+ if (err instanceof Error && err.name.startsWith('Jwt')) {
46
+ throw new AuthenticationError(err.message, { category: 'permissions' });
47
+ }
48
+ throw err;
49
+ }
50
+ }
51
+
52
+ token(request: WebRequest): AuthToken | undefined {
53
+ const value = (this.config.mode === 'header') ?
54
+ request.headers.getWithPrefix(this.config.header, this.config.headerPrefix) :
55
+ this.webAsyncContext.getValue(CookieJar).get(this.config.cookie, { signed: false });
56
+ return value ? { type: 'jwt', value } : undefined;
57
+ }
58
+
59
+ async decode(request: WebRequest): Promise<Principal | undefined> {
60
+ const token = this.token(request);
61
+ return token ? await this.verify(token.value) : undefined;
62
+ }
63
+
64
+ async create(value: Principal, keyId: string = 'default'): Promise<string> {
65
+ const keyRec = this.config.keyMap[keyId];
66
+ if (!keyRec) {
67
+ throw new AppError('Requested unknown key for signing');
68
+ }
69
+ const jwt = create({}, '-')
70
+ .setExpiration(value.expiresAt!)
71
+ .setIssuedAt(TimeUtil.asSeconds(value.issuedAt!))
72
+ .setClaim('core', castTo({ ...value }))
73
+ .setIssuer(value.issuer!)
74
+ .setJti(value.sessionId!)
75
+ .setSubject(value.id)
76
+ .setHeader('kid', keyRec.id)
77
+ .setSigningKey(keyRec.key)
78
+ .setSigningAlgorithm(this.#algorithm);
79
+ return jwt.toString();
80
+ }
81
+
82
+ async encode(response: WebResponse, data: Principal | undefined): Promise<WebResponse> {
83
+ const token = data ? await this.create(data) : undefined;
84
+ const { header, headerPrefix, cookie } = this.config;
85
+ if (this.config.mode === 'header') {
86
+ response.headers.setWithPrefix(header, token, headerPrefix);
87
+ } else {
88
+ this.webAsyncContext.getValue(CookieJar).set({ name: cookie, value: token, signed: false, expires: data?.expiresAt });
89
+ }
90
+ return response;
91
+ }
92
+ }
package/src/config.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { Config } from '@travetto/config';
2
+ import { Runtime, AppError, BinaryUtil } from '@travetto/runtime';
3
+ import { Ignore, Secret } from '@travetto/schema';
4
+
5
+ type KeyRec = { key: string, id: string };
6
+
7
+ @Config('web.auth')
8
+ export class WebAuthConfig {
9
+ applies: boolean = false;
10
+ mode: 'cookie' | 'header' = 'cookie';
11
+ header: string = 'Authorization';
12
+ cookie: string = 'trv_auth';
13
+ headerPrefix: string = 'Token';
14
+
15
+ @Secret()
16
+ signingKey?: string | string[];
17
+ @Ignore()
18
+ keyMap: Record<string, KeyRec> & { default?: KeyRec } = {};
19
+
20
+ postConstruct(): void {
21
+ if (!this.signingKey && Runtime.production) {
22
+ throw new AppError('The default signing key is only valid for development use, please specify a config value at web.auth.signingKey');
23
+ }
24
+ this.signingKey ??= 'dummy';
25
+
26
+ const all = [this.signingKey].flat().map(key => ({ key, id: BinaryUtil.hash(key, 8) }));
27
+ this.keyMap = Object.fromEntries(all.map(k => [k.id, k]));
28
+ this.keyMap.default = all[0];
29
+ }
30
+ }
@@ -0,0 +1,50 @@
1
+ import { ControllerRegistry, EndpointDecorator } from '@travetto/web';
2
+
3
+ import { AuthVerifyInterceptor } from './interceptors/verify.ts';
4
+ import { AuthLoginInterceptor } from './interceptors/login.ts';
5
+ import { AuthLogoutInterceptor } from './interceptors/logout.ts';
6
+
7
+ /**
8
+ * Authenticate an endpoint with a list of available identity sources
9
+ * @param source The symbol to target the specific authenticator
10
+ * @param sources Additional providers to support
11
+ * @augments `@travetto/auth:Authenticate`
12
+ */
13
+ export function Login(source: symbol, ...sources: symbol[]): EndpointDecorator {
14
+ return ControllerRegistry.createInterceptorConfigDecorator(AuthLoginInterceptor, {
15
+ providers: [source, ...sources],
16
+ applies: true
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Ensure the controller/endpoint is authenticated, give a set of permissions
22
+ * @param permissions Set of required/disallowed permissions
23
+ * @augments `@travetto/auth:Authenticated`
24
+ */
25
+ export function Authenticated(permissions: string[] = []): EndpointDecorator {
26
+ return ControllerRegistry.createInterceptorConfigDecorator(AuthVerifyInterceptor, {
27
+ state: 'authenticated',
28
+ permissions,
29
+ applies: true
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Require the controller/endpoint to be unauthenticated
35
+ * @augments `@travetto/auth:Unauthenticated`
36
+ */
37
+ export function Unauthenticated(): EndpointDecorator {
38
+ return ControllerRegistry.createInterceptorConfigDecorator(AuthVerifyInterceptor, {
39
+ state: 'unauthenticated',
40
+ applies: true
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Logs a user out of the auth state
46
+ * @augments `@travetto/auth:Logout`
47
+ */
48
+ export function Logout(): EndpointDecorator {
49
+ return ControllerRegistry.createInterceptorConfigDecorator(AuthLogoutInterceptor, { applies: true });
50
+ }
@@ -0,0 +1,78 @@
1
+ import { toConcrete } from '@travetto/runtime';
2
+ import { WebInterceptor, WebAsyncContext, WebInterceptorCategory, WebChainedContext, WebResponse } from '@travetto/web';
3
+ import { Injectable, Inject, DependencyRegistry } from '@travetto/di';
4
+ import { AuthContext, AuthService, AuthToken, Principal } from '@travetto/auth';
5
+
6
+ import { CommonPrincipalCodecSymbol, PrincipalCodec } from '../types.ts';
7
+ import { WebAuthConfig } from '../config.ts';
8
+
9
+ const toDate = (v: string | Date | undefined): Date | undefined => (typeof v === 'string') ? new Date(v) : v;
10
+
11
+ /**
12
+ * Auth Context interceptor
13
+ *
14
+ * - Supports the ability to encode context via response and decode via the request.
15
+ * - Connects the principal to the AuthContext
16
+ * - Manages expiry checks/extensions
17
+ */
18
+ @Injectable()
19
+ export class AuthContextInterceptor implements WebInterceptor {
20
+
21
+ category: WebInterceptorCategory = 'application';
22
+
23
+ @Inject({ optional: true })
24
+ codec: PrincipalCodec;
25
+
26
+ @Inject()
27
+ config: WebAuthConfig;
28
+
29
+ @Inject()
30
+ authContext: AuthContext;
31
+
32
+ @Inject()
33
+ authService: AuthService;
34
+
35
+ @Inject()
36
+ webAsyncContext: WebAsyncContext;
37
+
38
+ async postConstruct(): Promise<void> {
39
+ this.codec ??= await DependencyRegistry.getInstance(toConcrete<PrincipalCodec>(), CommonPrincipalCodecSymbol);
40
+ this.webAsyncContext.registerSource(toConcrete<Principal>(), () => this.authContext.principal);
41
+ this.webAsyncContext.registerSource(toConcrete<AuthToken>(), () => this.authContext.authToken);
42
+ }
43
+
44
+ async filter(ctx: WebChainedContext): Promise<WebResponse> {
45
+ // Skip if already authenticated
46
+ if (this.authContext.principal) {
47
+ return ctx.next();
48
+ }
49
+
50
+ try {
51
+ let lastExpiresAt: Date | undefined;
52
+ const decoded = await this.codec.decode(ctx.request);
53
+
54
+ if (decoded) {
55
+ lastExpiresAt = decoded.expiresAt = toDate(decoded.expiresAt);
56
+ decoded.issuedAt = toDate(decoded.issuedAt);
57
+ }
58
+
59
+ const checked = this.authService.enforceExpiry(decoded);
60
+ this.authContext.principal = checked;
61
+ this.authContext.authToken = await this.codec.token?.(ctx.request);
62
+
63
+ let value = await ctx.next();
64
+
65
+ const result = this.authContext.principal;
66
+ this.authService.manageExpiry(result);
67
+
68
+ if ((!!decoded !== !!checked) || result !== checked || lastExpiresAt !== result?.expiresAt) { // If it changed
69
+ value = await this.codec.encode(value, result);
70
+ }
71
+
72
+ return value;
73
+ } finally {
74
+ this.authContext.clear();
75
+ }
76
+ }
77
+
78
+ }
@@ -0,0 +1,48 @@
1
+ import { WebInterceptor, WebInterceptorCategory, WebChainedContext, WebResponse, WebInterceptorContext } from '@travetto/web';
2
+ import { Injectable, Inject } from '@travetto/di';
3
+ import { Config } from '@travetto/config';
4
+ import { Ignore } from '@travetto/schema';
5
+ import { AuthService } from '@travetto/auth';
6
+
7
+ import { AuthContextInterceptor } from './context.ts';
8
+
9
+ @Config('web.auth.login')
10
+ export class WebAuthLoginConfig {
11
+ /**
12
+ * Execute login on endpoint
13
+ */
14
+ applies = false;
15
+ /**
16
+ * The auth providers to iterate through when attempting to authenticate
17
+ */
18
+ @Ignore()
19
+ providers: symbol[];
20
+ }
21
+
22
+ /**
23
+ * Login interceptor
24
+ *
25
+ * - Supports the ability to encode context via request/response.
26
+ * - Connects the principal to the request
27
+ */
28
+ @Injectable()
29
+ export class AuthLoginInterceptor implements WebInterceptor<WebAuthLoginConfig> {
30
+
31
+ category: WebInterceptorCategory = 'application';
32
+ dependsOn = [AuthContextInterceptor];
33
+
34
+ @Inject()
35
+ config: WebAuthLoginConfig;
36
+
37
+ @Inject()
38
+ service: AuthService;
39
+
40
+ applies({ config }: WebInterceptorContext<WebAuthLoginConfig>): boolean {
41
+ return config.applies;
42
+ }
43
+
44
+ async filter(ctx: WebChainedContext<WebAuthLoginConfig>): Promise<WebResponse> {
45
+ await this.service.authenticate(ctx.request.body, ctx, ctx.config.providers ?? []);
46
+ return ctx.next();
47
+ }
48
+ }
@@ -0,0 +1,47 @@
1
+ import { WebInterceptor, WebInterceptorCategory, WebChainedContext, WebResponse, WebInterceptorContext } from '@travetto/web';
2
+ import { Injectable, Inject } from '@travetto/di';
3
+ import { Config } from '@travetto/config';
4
+ import { AuthContext, AuthenticationError } from '@travetto/auth';
5
+
6
+ import { AuthContextInterceptor } from './context.ts';
7
+
8
+ @Config('web.auth.logout')
9
+ export class WebAuthLogoutConfig {
10
+ /**
11
+ * Execute logout on endpoint
12
+ */
13
+ applies = false;
14
+ }
15
+
16
+ /**
17
+ * Logout interceptor
18
+ *
19
+ * Throws an error if the user is not logged in at time of logout
20
+ */
21
+ @Injectable()
22
+ export class AuthLogoutInterceptor implements WebInterceptor<WebAuthLogoutConfig> {
23
+
24
+ category: WebInterceptorCategory = 'application';
25
+ dependsOn = [AuthContextInterceptor];
26
+
27
+ @Inject()
28
+ config: WebAuthLogoutConfig;
29
+
30
+ @Inject()
31
+ authContext: AuthContext;
32
+
33
+ applies({ config }: WebInterceptorContext<WebAuthLogoutConfig>): boolean {
34
+ return config.applies;
35
+ }
36
+
37
+ async filter({ next }: WebChainedContext): Promise<WebResponse> {
38
+ try {
39
+ if (!this.authContext.principal) {
40
+ throw new AuthenticationError('Already logged out');
41
+ }
42
+ return await next();
43
+ } finally {
44
+ await this.authContext.clear();
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,88 @@
1
+ import { AppError, Util } from '@travetto/runtime';
2
+ import { WebInterceptor, WebInterceptorCategory, WebChainedContext, WebResponse, WebInterceptorContext } from '@travetto/web';
3
+ import { Injectable, Inject } from '@travetto/di';
4
+ import { Config } from '@travetto/config';
5
+ import { Ignore } from '@travetto/schema';
6
+ import { AuthenticationError, AuthContext } from '@travetto/auth';
7
+
8
+ import { AuthContextInterceptor } from './context.ts';
9
+
10
+ function matchPermissionSet(rule: string[], perms: Set<string>): boolean {
11
+ for (const el of rule) {
12
+ if (!perms.has(el)) {
13
+ return false;
14
+ }
15
+ }
16
+ return true;
17
+ }
18
+
19
+ @Config('web.auth.verify')
20
+ export class WebAuthVerifyConfig {
21
+ /**
22
+ * Verify user is in a specific auth state
23
+ */
24
+ applies = false;
25
+ /**
26
+ * Default state to care about
27
+ */
28
+ state?: 'authenticated' | 'unauthenticated';
29
+ /**
30
+ * What are the permissions for verification, allowed or disallowed
31
+ */
32
+ permissions: string[] = [];
33
+
34
+ @Ignore()
35
+ matcher: (key: Set<string>) => boolean;
36
+ }
37
+
38
+ /**
39
+ * Authenticate interceptor
40
+ *
41
+ * Enforces if the user should be authenticated
42
+ */
43
+ @Injectable()
44
+ export class AuthVerifyInterceptor implements WebInterceptor<WebAuthVerifyConfig> {
45
+
46
+ category: WebInterceptorCategory = 'application';
47
+ dependsOn = [AuthContextInterceptor];
48
+
49
+ @Inject()
50
+ config: WebAuthVerifyConfig;
51
+
52
+ @Inject()
53
+ authContext: AuthContext;
54
+
55
+ finalizeConfig({ config }: WebInterceptorContext<WebAuthVerifyConfig>): WebAuthVerifyConfig {
56
+ config.matcher = Util.allowDeny<string[], [Set<string>]>(config.permissions ?? [],
57
+ x => x.split('|'),
58
+ matchPermissionSet,
59
+ );
60
+ return config;
61
+ }
62
+
63
+ applies({ config }: WebInterceptorContext<WebAuthVerifyConfig>): boolean {
64
+ return config.applies;
65
+ }
66
+
67
+ async filter({ config, next }: WebChainedContext<WebAuthVerifyConfig>): Promise<WebResponse> {
68
+ const principal = this.authContext.principal;
69
+
70
+ switch (config.state) {
71
+ case 'authenticated': {
72
+ if (!principal) {
73
+ throw new AuthenticationError('User is unauthenticated');
74
+ } else if (!config.matcher(new Set(principal.permissions))) {
75
+ throw new AppError('Access denied', { category: 'permissions' });
76
+ }
77
+ break;
78
+ }
79
+ case 'unauthenticated': {
80
+ if (principal) {
81
+ throw new AuthenticationError('User is authenticated');
82
+ }
83
+ break;
84
+ }
85
+ }
86
+ return next();
87
+ }
88
+ }
@@ -0,0 +1,19 @@
1
+ import { Schema, SchemaRegistry } from '@travetto/schema';
2
+ import { toConcrete, AnyMap, asFull } from '@travetto/runtime';
3
+ import { Principal } from '@travetto/auth';
4
+
5
+ @Schema()
6
+ export class PrincipalSchema implements Principal {
7
+ id: string;
8
+ details: AnyMap;
9
+ expiresAt?: Date | undefined;
10
+ issuedAt?: Date | undefined;
11
+ issuer?: string | undefined;
12
+ sessionId?: string | undefined;
13
+ permissions?: string[] | undefined;
14
+ }
15
+
16
+ SchemaRegistry.mergeConfigs(
17
+ asFull(SchemaRegistry.getOrCreatePending(toConcrete<Principal>())),
18
+ SchemaRegistry.getOrCreatePending(PrincipalSchema)
19
+ );
package/src/types.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { AuthToken, Principal } from '@travetto/auth';
2
+ import { WebRequest, WebResponse } from '@travetto/web';
3
+
4
+ export const CommonPrincipalCodecSymbol = Symbol.for('@travetto/auth-web:common-codec');
5
+
6
+ /**
7
+ * Web codec for reading/writing principal
8
+ * @concrete
9
+ */
10
+ export interface PrincipalCodec {
11
+ /**
12
+ * Extract token for re-use elsewhere
13
+ */
14
+ token?(request: WebRequest): Promise<AuthToken | undefined> | AuthToken | undefined;
15
+ /**
16
+ * Encode data
17
+ */
18
+ encode(payload: WebResponse, data: Principal | undefined): Promise<WebResponse> | WebResponse;
19
+ /**
20
+ * Decode data
21
+ */
22
+ decode(request: WebRequest): Promise<Principal | undefined> | Principal | undefined;
23
+ }
@@ -0,0 +1,287 @@
1
+ import timers from 'node:timers/promises';
2
+ import assert from 'node:assert';
3
+
4
+ import { Controller, Get, WebHeaders, WebResponse, Post } from '@travetto/web';
5
+ import { Suite, Test } from '@travetto/test';
6
+ import { DependencyRegistry, Inject, InjectableFactory } from '@travetto/di';
7
+ import { AuthenticationError, Authenticator, AuthContext, AuthConfig } from '@travetto/auth';
8
+
9
+ import { InjectableSuite } from '@travetto/di/support/test/suite.ts';
10
+ import { BaseWebSuite } from '@travetto/web/support/test/suite/base.ts';
11
+
12
+ import { Login, Authenticated, Logout } from '../../src/decorator.ts';
13
+ import { WebAuthConfig } from '../../src/config.ts';
14
+ import { CommonPrincipalCodecSymbol } from '../../src/types.ts';
15
+ import { JWTPrincipalCodec } from '../../src/codec.ts';
16
+
17
+ const TestAuthSymbol = Symbol.for('TEST_AUTH');
18
+
19
+ class Config {
20
+ @InjectableFactory(TestAuthSymbol)
21
+ static getAuthenticator(cfg: AuthConfig): Authenticator {
22
+ cfg.rollingRenew = true;
23
+ cfg.maxAgeMs = 2000;
24
+
25
+ return {
26
+ async authenticate(body: { username?: string, password?: string }) {
27
+ if (body.username === 'super-user' && body.password === 'password') {
28
+ return {
29
+ id: '5',
30
+ details: { name: 'Billy' },
31
+ permissions: ['perm1', 'perm2'],
32
+ issuer: 'custom',
33
+ };
34
+ }
35
+ throw new AuthenticationError('User unknown');
36
+ }
37
+ };
38
+ }
39
+ }
40
+
41
+ @Controller('/test/auth')
42
+ class TestAuthController {
43
+
44
+ @Inject()
45
+ authContext: AuthContext;
46
+
47
+ @Post('/login')
48
+ @Login(TestAuthSymbol)
49
+ async simpleLogin() {
50
+ console.log('hello');
51
+ }
52
+
53
+ @Get('/self')
54
+ @Authenticated()
55
+ async getSelf() {
56
+ return this.authContext.principal;
57
+ }
58
+
59
+ @Get('/token')
60
+ @Authenticated()
61
+ async getToken() {
62
+ return this.authContext.authToken?.value;
63
+ }
64
+
65
+
66
+ @Get('/logout')
67
+ @Logout()
68
+ async logout() {
69
+ return WebResponse.redirect('/auth/self');
70
+ }
71
+ }
72
+
73
+ @Controller('/test/auth-all')
74
+ @Authenticated()
75
+ class TestAuthAllController {
76
+
77
+ @Inject()
78
+ authContext: AuthContext;
79
+
80
+ @Get('/self')
81
+ async getSelf() {
82
+ return this.authContext.principal;
83
+ }
84
+ }
85
+
86
+ @Suite()
87
+ @InjectableSuite()
88
+ export abstract class AuthWebServerSuite extends BaseWebSuite {
89
+
90
+ @Inject()
91
+ config: WebAuthConfig;
92
+
93
+ getCookie(headers: WebHeaders): string | undefined {
94
+ return headers.getSetCookie()[0];
95
+ }
96
+
97
+ getCookieValue(headers: WebHeaders): string | undefined {
98
+ return this.getCookie(headers)?.split(';')[0];
99
+ }
100
+
101
+ getCookieExpires(headers: WebHeaders): Date | undefined {
102
+ const v = this.getCookie(headers)?.match('expires=([^;]+)(;|$)')?.[1];
103
+ return v ? new Date(v) : undefined;
104
+ }
105
+
106
+ @Test()
107
+ async testBadAuth() {
108
+ const { context: { httpStatusCode: statusCode } } = await this.request({
109
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
110
+ body: {
111
+ username: 'Todd',
112
+ password: 'Rod'
113
+ }
114
+ }, false);
115
+ assert(statusCode === 401);
116
+ }
117
+
118
+ @Test()
119
+ async testGoodAuth() {
120
+ this.config.mode = 'cookie';
121
+
122
+ const { headers, context: { httpStatusCode: statusCode } } = await this.request({
123
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
124
+ body: {
125
+ username: 'super-user',
126
+ password: 'password'
127
+ }
128
+ }, false);
129
+ assert(headers.getSetCookie().length);
130
+ assert(statusCode === 201);
131
+ }
132
+
133
+ @Test()
134
+ async testBlockedAuthenticated() {
135
+ this.config.mode = 'header';
136
+
137
+ const { context: { httpStatusCode: statusCode } } = await this.request({
138
+ context: { httpMethod: 'GET', path: '/test/auth/self' }
139
+ }, false);
140
+ assert(statusCode === 401);
141
+ }
142
+
143
+ @Test()
144
+ async testGoodAuthenticatedCookie() {
145
+ this.config.mode = 'cookie';
146
+
147
+ const { headers, context: { httpStatusCode: statusCode } } = await this.request({
148
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
149
+ body: {
150
+ username: 'super-user',
151
+ password: 'password'
152
+ }
153
+ }, false);
154
+ assert(statusCode === 201);
155
+ const cookie = this.getCookieValue(headers);
156
+ assert(cookie);
157
+
158
+ const { context: { httpStatusCode: lastStatus } } = await this.request({
159
+ context: { httpMethod: 'GET', path: '/test/auth/self' },
160
+ headers: { cookie }
161
+ }, false);
162
+ assert(lastStatus === 200);
163
+ }
164
+
165
+ @Test()
166
+ async testGoodAuthenticatedHeader() {
167
+ this.config.mode = 'header';
168
+
169
+ const { headers, context: { httpStatusCode: statusCode } } = await this.request({
170
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
171
+ body: {
172
+ username: 'super-user',
173
+ password: 'password'
174
+ }
175
+ }, false);
176
+ assert(statusCode === 201);
177
+
178
+ const { context: { httpStatusCode: lastStatus } } = await this.request({
179
+ context: { httpMethod: 'GET', path: '/test/auth/self' },
180
+ headers: {
181
+ Authorization: headers.get('Authorization')!
182
+ }
183
+ }, false);
184
+ assert(lastStatus === 200);
185
+ }
186
+
187
+ @Test()
188
+ async testAllAuthenticated() {
189
+ this.config.mode = 'header';
190
+
191
+ const { context: { httpStatusCode: statusCode } } = await this.request({
192
+ context: { httpMethod: 'GET', path: '/test/auth-all/self' }
193
+ }, false);
194
+ assert(statusCode === 401);
195
+
196
+ const { headers, context: { httpStatusCode: authStatus } } = await this.request({
197
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
198
+ body: {
199
+ username: 'super-user',
200
+ password: 'password'
201
+ }
202
+ }, false);
203
+ assert(authStatus === 201);
204
+
205
+
206
+ const { context: { httpStatusCode: lastStatus } } = await this.request({
207
+ context: { httpMethod: 'GET', path: '/test/auth-all/self' },
208
+ headers: {
209
+ Authorization: headers.get('Authorization')!
210
+ }
211
+ }, false);
212
+ assert(lastStatus === 200);
213
+ }
214
+
215
+
216
+ @Test()
217
+ async testTokenRetrieval() {
218
+ this.config.mode = 'cookie';
219
+
220
+ const { headers, context: { httpStatusCode: statusCode } } = await this.request({
221
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
222
+ body: {
223
+ username: 'super-user',
224
+ password: 'password'
225
+ }
226
+ }, false);
227
+ assert(statusCode === 201);
228
+ const cookie = this.getCookieValue(headers);
229
+ assert(cookie);
230
+
231
+ const { body, context: { httpStatusCode: lastStatus } } = await this.request({
232
+ context: { httpMethod: 'GET', path: '/test/auth/token' },
233
+ headers: { cookie }
234
+ }, false);
235
+ assert(lastStatus === 200);
236
+ assert(typeof body === 'string');
237
+
238
+ const codec = await DependencyRegistry.getInstance(JWTPrincipalCodec, CommonPrincipalCodecSymbol);
239
+ await assert.doesNotReject(() => codec.verify(body));
240
+ }
241
+
242
+ @Test()
243
+ async testCookieRollingRenewAuthenticated() {
244
+ this.config.mode = 'cookie';
245
+
246
+ const { headers, context: { httpStatusCode: statusCode } } = await this.request({
247
+ context: { httpMethod: 'POST', path: '/test/auth/login' },
248
+ body: {
249
+ username: 'super-user',
250
+ password: 'password'
251
+ }
252
+ }, false);
253
+ assert(statusCode === 201);
254
+
255
+ const start = Date.now();
256
+ const cookie = this.getCookieValue(headers);
257
+ assert(cookie);
258
+
259
+ const expires = this.getCookieExpires(headers);
260
+ assert(expires);
261
+
262
+ const { headers: selfHeaders, context: { httpStatusCode: lastStatus } } = await this.request({
263
+ context: { httpMethod: 'GET', path: '/test/auth/self' },
264
+ headers: { cookie }
265
+ }, false);
266
+ assert(this.getCookie(selfHeaders) === undefined);
267
+ assert(lastStatus === 200);
268
+
269
+ const used = (Date.now() - start);
270
+ assert(used < 1000);
271
+ await timers.setTimeout((2000 - used) / 2);
272
+
273
+ const { headers: selfHeadersRenew, context: { httpStatusCode: lastStatus2 } } = await this.request({
274
+ context: { httpMethod: 'GET', path: '/test/auth/self' },
275
+ headers: { cookie }
276
+ }, false);
277
+ assert(lastStatus2 === 200);
278
+ assert(this.getCookie(selfHeadersRenew));
279
+
280
+ const expiresRenew = this.getCookieExpires(selfHeadersRenew);
281
+ assert(expiresRenew);
282
+
283
+ const delta = expiresRenew.getTime() - expires.getTime();
284
+ assert(delta < 1800);
285
+ assert(delta > 500);
286
+ }
287
+ }