@sasjs/api 0.2.1 → 0.3.0
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 +28 -8
- package/build/browser.js +788 -4
- package/build/cjs/corpus/identity.js +268 -0
- package/build/cjs/corpus/runner.js +11 -2
- package/build/cjs/host/dispatch.js +57 -2
- package/build/cjs/identity/fixture.js +118 -0
- package/build/cjs/identity/memory-store.js +179 -0
- package/build/cjs/identity/service.js +142 -0
- package/build/cjs/identity/types.js +11 -0
- package/build/cjs/index.js +11 -1
- package/build/cjs/routes/generated.js +72 -0
- package/build/cjs/routes/index.js +6 -0
- package/build/cjs/routes/overlay.js +38 -0
- package/build/esm/corpus/drive.d.ts +14 -0
- package/build/esm/corpus/drive.d.ts.map +1 -1
- package/build/esm/corpus/identity.d.ts +17 -0
- package/build/esm/corpus/identity.d.ts.map +1 -0
- package/build/esm/corpus/identity.js +265 -0
- package/build/esm/corpus/runner.d.ts +6 -1
- package/build/esm/corpus/runner.d.ts.map +1 -1
- package/build/esm/corpus/runner.js +11 -2
- package/build/esm/host/dispatch.d.ts +16 -0
- package/build/esm/host/dispatch.d.ts.map +1 -1
- package/build/esm/host/dispatch.js +57 -2
- package/build/esm/identity/fixture.d.ts +84 -0
- package/build/esm/identity/fixture.d.ts.map +1 -0
- package/build/esm/identity/fixture.js +114 -0
- package/build/esm/identity/memory-store.d.ts +51 -0
- package/build/esm/identity/memory-store.d.ts.map +1 -0
- package/build/esm/identity/memory-store.js +175 -0
- package/build/esm/identity/service.d.ts +48 -0
- package/build/esm/identity/service.d.ts.map +1 -0
- package/build/esm/identity/service.js +137 -0
- package/build/esm/identity/types.d.ts +79 -0
- package/build/esm/identity/types.d.ts.map +1 -0
- package/build/esm/identity/types.js +10 -0
- package/build/esm/index.d.ts +7 -0
- package/build/esm/index.d.ts.map +1 -1
- package/build/esm/index.js +4 -0
- package/build/esm/routes/generated.d.ts.map +1 -1
- package/build/esm/routes/generated.js +72 -0
- package/build/esm/routes/index.d.ts.map +1 -1
- package/build/esm/routes/index.js +6 -0
- package/build/esm/routes/overlay.d.ts +9 -0
- package/build/esm/routes/overlay.d.ts.map +1 -1
- package/build/esm/routes/overlay.js +38 -0
- package/build/esm/types.d.ts +7 -0
- package/build/esm/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { DEFAULT_IDENTITY } from '../identity/fixture.js';
|
|
2
|
+
/** The default fixture, plus a caller that is already signed in. */
|
|
3
|
+
export const SIGNED_IN = {
|
|
4
|
+
...DEFAULT_IDENTITY,
|
|
5
|
+
tokens: [
|
|
6
|
+
{
|
|
7
|
+
username: 'user2',
|
|
8
|
+
clientId: 'web_app',
|
|
9
|
+
accessToken: 'seed-access',
|
|
10
|
+
refreshToken: 'seed-refresh'
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
username: 'admin',
|
|
14
|
+
clientId: 'removed_client',
|
|
15
|
+
accessToken: 'seed-access-orphan',
|
|
16
|
+
refreshToken: 'seed-refresh-orphan'
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
codes: [
|
|
20
|
+
{ code: 'seed-code', username: 'user2', clientId: 'web_app' },
|
|
21
|
+
{ code: 'seed-orphan-code', username: 'user2', clientId: 'removed_client' },
|
|
22
|
+
// the same shape with a pair already issued: the exchange must still refuse,
|
|
23
|
+
// rather than handing back tokens that belong to a client that is gone
|
|
24
|
+
{
|
|
25
|
+
code: 'seed-orphan-pair-code',
|
|
26
|
+
username: 'admin',
|
|
27
|
+
clientId: 'removed_client'
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
/** The same identity, with the restricted user switched off. */
|
|
32
|
+
export const DEACTIVATED = {
|
|
33
|
+
...SIGNED_IN,
|
|
34
|
+
users: SIGNED_IN.users.map((user) => user.username === 'user2' ? { ...user, isActive: false } : user)
|
|
35
|
+
};
|
|
36
|
+
/** The user the seeded session belongs to, without the host's own identifier. */
|
|
37
|
+
const user2 = {
|
|
38
|
+
username: 'user2',
|
|
39
|
+
displayName: 'Demo User',
|
|
40
|
+
isAdmin: false,
|
|
41
|
+
needsToUpdatePassword: false
|
|
42
|
+
};
|
|
43
|
+
export const IDENTITY_CORPUS = [
|
|
44
|
+
{
|
|
45
|
+
id: 'login',
|
|
46
|
+
route: 'POST /SASLogon/login',
|
|
47
|
+
description: 'signs a seeded user in',
|
|
48
|
+
request: { body: { username: 'admin', password: 'admin123' } },
|
|
49
|
+
expect: {
|
|
50
|
+
status: 200,
|
|
51
|
+
partial: {
|
|
52
|
+
loggedIn: true,
|
|
53
|
+
user: {
|
|
54
|
+
username: 'admin',
|
|
55
|
+
displayName: 'Demo Admin',
|
|
56
|
+
isAdmin: true,
|
|
57
|
+
needsToUpdatePassword: false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'login-missing-username',
|
|
64
|
+
route: 'POST /SASLogon/login',
|
|
65
|
+
description: 'refuses a sign-in with no username as 400, naming the field',
|
|
66
|
+
request: { body: { password: 'admin123' } },
|
|
67
|
+
expect: { status: 400, error: '"username" is required' }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'login-deactivated',
|
|
71
|
+
route: 'POST /SASLogon/login',
|
|
72
|
+
description: 'refuses a deactivated account, naming it',
|
|
73
|
+
identity: DEACTIVATED,
|
|
74
|
+
request: { body: { username: 'user2', password: 'user123' } },
|
|
75
|
+
expect: { status: 401, error: 'This account is not active.' }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'login-wrong-password',
|
|
79
|
+
route: 'POST /SASLogon/login',
|
|
80
|
+
description: 'refuses a wrong password as 401, naming it',
|
|
81
|
+
request: { body: { username: 'admin', password: 'wrong123' } },
|
|
82
|
+
expect: { status: 401, error: 'Invalid Password.' }
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'login-unknown-user',
|
|
86
|
+
route: 'POST /SASLogon/login',
|
|
87
|
+
description: 'refuses an unknown username as 401, naming it',
|
|
88
|
+
request: { body: { username: 'nobody', password: 'admin123' } },
|
|
89
|
+
expect: { status: 401, error: 'Username is not found.' }
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'authorize',
|
|
93
|
+
route: 'POST /SASLogon/authorize',
|
|
94
|
+
description: 'issues a code to a signed-in caller',
|
|
95
|
+
identity: SIGNED_IN,
|
|
96
|
+
request: {
|
|
97
|
+
token: 'seed-access',
|
|
98
|
+
body: { clientId: 'web_app' }
|
|
99
|
+
},
|
|
100
|
+
expect: { status: 200, keys: ['code'] }
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'authorize-missing-client',
|
|
104
|
+
route: 'POST /SASLogon/authorize',
|
|
105
|
+
description: 'refuses an authorize with no clientId as 400, naming the field',
|
|
106
|
+
identity: SIGNED_IN,
|
|
107
|
+
request: { token: 'seed-access', body: {} },
|
|
108
|
+
expect: { status: 400, error: '"clientId" is required' }
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'authorize-without-session',
|
|
112
|
+
route: 'POST /SASLogon/authorize',
|
|
113
|
+
description: 'refuses with 401 when no caller is signed in',
|
|
114
|
+
request: { body: { clientId: 'web_app' } },
|
|
115
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'authorize-unknown-client',
|
|
119
|
+
route: 'POST /SASLogon/authorize',
|
|
120
|
+
description: 'refuses an unknown client with 403',
|
|
121
|
+
identity: SIGNED_IN,
|
|
122
|
+
request: { token: 'seed-access', body: { clientId: 'nope' } },
|
|
123
|
+
expect: { status: 403, error: 'Invalid clientId.' }
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'exchange-code',
|
|
127
|
+
route: 'POST /SASjsApi/auth/token',
|
|
128
|
+
description: 'exchanges a code for a token pair',
|
|
129
|
+
identity: SIGNED_IN,
|
|
130
|
+
request: { body: { clientId: 'web_app', code: 'seed-code' } },
|
|
131
|
+
expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'exchange-missing-client',
|
|
135
|
+
route: 'POST /SASjsApi/auth/token',
|
|
136
|
+
description: 'refuses an exchange with no clientId as 400, naming the field',
|
|
137
|
+
identity: SIGNED_IN,
|
|
138
|
+
request: { body: { code: 'seed-code' } },
|
|
139
|
+
expect: { status: 400, error: '"clientId" is required' }
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'exchange-code-unknown',
|
|
143
|
+
route: 'POST /SASjsApi/auth/token',
|
|
144
|
+
description: 'refuses a code the host never issued',
|
|
145
|
+
request: { body: { clientId: 'web_app', code: 'not-a-code' } },
|
|
146
|
+
expect: { status: 401, error: 'Invalid Auth Code' }
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: 'exchange-code-other-client',
|
|
150
|
+
route: 'POST /SASjsApi/auth/token',
|
|
151
|
+
description: 'refuses a code presented for another client, as a bad code',
|
|
152
|
+
identity: SIGNED_IN,
|
|
153
|
+
request: { body: { clientId: 'sasjs', code: 'seed-code' } },
|
|
154
|
+
expect: { status: 401, error: 'Invalid Auth Code' }
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'exchange-code-client-gone',
|
|
158
|
+
route: 'POST /SASjsApi/auth/token',
|
|
159
|
+
description: 'refuses a valid code whose client no longer exists',
|
|
160
|
+
identity: SIGNED_IN,
|
|
161
|
+
request: { body: { clientId: 'removed_client', code: 'seed-orphan-code' } },
|
|
162
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'exchange-code-client-gone-with-pair',
|
|
166
|
+
route: 'POST /SASjsApi/auth/token',
|
|
167
|
+
description: 'refuses an exchange whose client is gone even when a pair was issued before it was deleted',
|
|
168
|
+
identity: SIGNED_IN,
|
|
169
|
+
request: {
|
|
170
|
+
body: { clientId: 'removed_client', code: 'seed-orphan-pair-code' }
|
|
171
|
+
},
|
|
172
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'exchange-code-deactivated',
|
|
176
|
+
route: 'POST /SASjsApi/auth/token',
|
|
177
|
+
description: 'refuses an exchange for an account that has been deactivated',
|
|
178
|
+
identity: DEACTIVATED,
|
|
179
|
+
request: { body: { clientId: 'web_app', code: 'seed-code' } },
|
|
180
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'refresh',
|
|
184
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
185
|
+
description: 'mints a new pair from a refresh token',
|
|
186
|
+
identity: SIGNED_IN,
|
|
187
|
+
request: { token: 'seed-refresh' },
|
|
188
|
+
expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'refresh-invalid',
|
|
192
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
193
|
+
description: 'refuses a refresh token it did not issue',
|
|
194
|
+
request: { token: 'not-a-token' },
|
|
195
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: 'refresh-without-token',
|
|
199
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
200
|
+
description: 'refuses a refresh with no token at all',
|
|
201
|
+
request: {},
|
|
202
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'refresh-client-gone',
|
|
206
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
207
|
+
description: 'refuses a refresh whose client no longer exists',
|
|
208
|
+
identity: SIGNED_IN,
|
|
209
|
+
request: { token: 'seed-refresh-orphan' },
|
|
210
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'refresh-deactivated',
|
|
214
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
215
|
+
description: 'refuses a refresh for an account that has been deactivated, as the server does on every request',
|
|
216
|
+
identity: DEACTIVATED,
|
|
217
|
+
request: { token: 'seed-refresh' },
|
|
218
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: 'logout',
|
|
222
|
+
route: 'POST /SASjsApi/auth/logout',
|
|
223
|
+
description: 'revokes the caller pair and answers 204',
|
|
224
|
+
identity: SIGNED_IN,
|
|
225
|
+
request: { token: 'seed-access' },
|
|
226
|
+
expect: { status: 204 }
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'logout-without-token',
|
|
230
|
+
route: 'POST /SASjsApi/auth/logout',
|
|
231
|
+
description: 'refuses a logout with no valid token',
|
|
232
|
+
request: {},
|
|
233
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
id: 'session',
|
|
237
|
+
route: 'GET /SASjsApi/session',
|
|
238
|
+
description: 'reports the caller the token names',
|
|
239
|
+
identity: SIGNED_IN,
|
|
240
|
+
request: { token: 'seed-access' },
|
|
241
|
+
expect: { status: 200, partial: user2 }
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'session-deactivated',
|
|
245
|
+
route: 'GET /SASjsApi/session',
|
|
246
|
+
description: 'stops resolving a token whose account has been deactivated',
|
|
247
|
+
identity: DEACTIVATED,
|
|
248
|
+
request: { token: 'seed-access' },
|
|
249
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: 'session-without-token',
|
|
253
|
+
route: 'GET /SASjsApi/session',
|
|
254
|
+
description: 'refuses the session read with no caller',
|
|
255
|
+
request: {},
|
|
256
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: 'sign-out',
|
|
260
|
+
route: 'GET /SASLogon/logout',
|
|
261
|
+
description: 'answers the body the browser sign-out returns',
|
|
262
|
+
request: {},
|
|
263
|
+
expect: { status: 200, contents: 'OK!' }
|
|
264
|
+
}
|
|
265
|
+
];
|
|
@@ -16,7 +16,12 @@ export interface CaseResult {
|
|
|
16
16
|
/** The refusal's message, when the case was refused. */
|
|
17
17
|
error?: string;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Services over fresh stores, seeded with the case's files and identity.
|
|
21
|
+
*
|
|
22
|
+
* The identity store is built for every case, drive or not, so a case that starts
|
|
23
|
+
* mid-flow can declare the tokens and codes it begins with.
|
|
24
|
+
*/
|
|
20
25
|
export declare const servicesFor: (testCase: CorpusCase) => Services;
|
|
21
26
|
/**
|
|
22
27
|
* Runs a corpus case against the drive the case seeds itself, or against
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../../../src/corpus/runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAE5C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAA;AAQnD,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,UAAU,UAAU,KAAG,QAKjD,CAAA;AAEF;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAClB,UAAU,UAAU,EACpB,WAAU,QAAgC,KACzC,OAAO,CAAC,UAAU,CAcpB,CAAA"}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { dispatch } from '../host/dispatch.js';
|
|
2
2
|
import { createDriveService } from '../drive/service.js';
|
|
3
3
|
import { MemoryDriveStore } from '../drive/memory-store.js';
|
|
4
|
+
import { createIdentityService } from '../identity/service.js';
|
|
5
|
+
import { MemoryIdentityStore } from '../identity/memory-store.js';
|
|
6
|
+
import { DEFAULT_IDENTITY } from '../identity/fixture.js';
|
|
4
7
|
import { isApiError } from '../types.js';
|
|
5
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Services over fresh stores, seeded with the case's files and identity.
|
|
10
|
+
*
|
|
11
|
+
* The identity store is built for every case, drive or not, so a case that starts
|
|
12
|
+
* mid-flow can declare the tokens and codes it begins with.
|
|
13
|
+
*/
|
|
6
14
|
export const servicesFor = (testCase) => ({
|
|
7
|
-
drive: createDriveService(new MemoryDriveStore(testCase.seed || {}))
|
|
15
|
+
drive: createDriveService(new MemoryDriveStore(testCase.seed || {})),
|
|
16
|
+
identity: createIdentityService(new MemoryIdentityStore(testCase.identity || DEFAULT_IDENTITY))
|
|
8
17
|
});
|
|
9
18
|
/**
|
|
10
19
|
* Runs a corpus case against the drive the case seeds itself, or against
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { DriveService } from '../drive/service.js';
|
|
2
|
+
import type { IdentityService } from '../identity/service.js';
|
|
3
|
+
import type { Subject } from '../identity/types.js';
|
|
2
4
|
export interface ApiRequest {
|
|
3
5
|
query?: Record<string, string | undefined>;
|
|
4
6
|
body?: Record<string, unknown>;
|
|
@@ -7,6 +9,14 @@ export interface ApiRequest {
|
|
|
7
9
|
name: string;
|
|
8
10
|
contents: string | Uint8Array;
|
|
9
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The bearer token the request carried, for the routes that name their caller
|
|
14
|
+
* in a header rather than a body. A host that already resolved the caller - a
|
|
15
|
+
* session cookie - passes `subject` instead.
|
|
16
|
+
*/
|
|
17
|
+
token?: string;
|
|
18
|
+
/** The caller, when the host resolved one before dispatching. */
|
|
19
|
+
subject?: Subject;
|
|
10
20
|
}
|
|
11
21
|
export interface ApiResponse {
|
|
12
22
|
status: number;
|
|
@@ -17,6 +27,12 @@ export interface ApiResponse {
|
|
|
17
27
|
}
|
|
18
28
|
export interface Services {
|
|
19
29
|
drive: DriveService;
|
|
30
|
+
/**
|
|
31
|
+
* Users, clients and tokens. A host without one answers 501 for the identity
|
|
32
|
+
* routes, which is the same answer as an operation this package does not
|
|
33
|
+
* implement - the difference is the message, and it names the capability.
|
|
34
|
+
*/
|
|
35
|
+
identity?: IdentityService;
|
|
20
36
|
}
|
|
21
37
|
export declare const dispatch: (services: Services, routeId: string, request?: ApiRequest) => Promise<ApiResponse>;
|
|
22
38
|
/** Whether a route is one `dispatch` can answer today. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../../src/host/dispatch.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"dispatch.d.ts","sourceRoot":"","sources":["../../../src/host/dispatch.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAA;AAGnD,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,oFAAoF;IACpF,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAG,UAAU,CAAA;KAAE,CAAA;IACtD;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iEAAiE;IACjE,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,mBAAmB;IACnB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,GAAG,UAAU,CAAA;CAC/B;AAED,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,YAAY,CAAA;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,eAAe,CAAA;CAC3B;AAsCD,eAAO,MAAM,QAAQ,GACnB,UAAU,QAAQ,EAClB,SAAS,MAAM,EACf,UAAS,UAAe,KACvB,OAAO,CAAC,WAAW,CAqHrB,CAAA;AAED,0DAA0D;AAC1D,eAAO,MAAM,cAAc,EAAE,MAAM,EAiBlC,CAAA"}
|
|
@@ -39,8 +39,56 @@ export const dispatch = async (services, routeId, request = {}) => {
|
|
|
39
39
|
if (!route)
|
|
40
40
|
throw new ApiError(404, `No such operation: ${routeId}`);
|
|
41
41
|
validate(route, request);
|
|
42
|
-
const { drive } = services;
|
|
42
|
+
const { drive, identity } = services;
|
|
43
43
|
const query = request.query || {};
|
|
44
|
+
const body = request.body || {};
|
|
45
|
+
/** The caller, from the request's own subject or the token it carried. */
|
|
46
|
+
const caller = async () => {
|
|
47
|
+
if (request.subject)
|
|
48
|
+
return request.subject;
|
|
49
|
+
return identity?.authenticate(request.token);
|
|
50
|
+
};
|
|
51
|
+
if (routeId.startsWith('POST /SASjsApi/auth/') ||
|
|
52
|
+
routeId.startsWith('POST /SASLogon/') ||
|
|
53
|
+
routeId === 'GET /SASLogon/logout' ||
|
|
54
|
+
routeId === 'GET /SASjsApi/session') {
|
|
55
|
+
if (!identity)
|
|
56
|
+
throw new ApiError(501, `This host has no identity service, so it cannot answer ${routeId}.`);
|
|
57
|
+
switch (routeId) {
|
|
58
|
+
case 'POST /SASjsApi/auth/token':
|
|
59
|
+
return {
|
|
60
|
+
status: 200,
|
|
61
|
+
body: await identity.token({
|
|
62
|
+
clientId: String(body.clientId ?? ''),
|
|
63
|
+
code: String(body.code ?? '')
|
|
64
|
+
})
|
|
65
|
+
};
|
|
66
|
+
case 'POST /SASjsApi/auth/refresh':
|
|
67
|
+
return { status: 200, body: await identity.refresh(request.token) };
|
|
68
|
+
case 'POST /SASjsApi/auth/logout':
|
|
69
|
+
await identity.logout(await caller());
|
|
70
|
+
return { status: 204 };
|
|
71
|
+
case 'POST /SASLogon/login':
|
|
72
|
+
return {
|
|
73
|
+
status: 200,
|
|
74
|
+
body: await identity.login({
|
|
75
|
+
username: String(body.username ?? ''),
|
|
76
|
+
password: String(body.password ?? '')
|
|
77
|
+
})
|
|
78
|
+
};
|
|
79
|
+
case 'POST /SASLogon/authorize':
|
|
80
|
+
return {
|
|
81
|
+
status: 200,
|
|
82
|
+
body: {
|
|
83
|
+
code: await identity.authorize(await caller(), String(body.clientId ?? ''))
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
case 'GET /SASLogon/logout':
|
|
87
|
+
return { status: 200, contents: await identity.logoutSession() };
|
|
88
|
+
case 'GET /SASjsApi/session':
|
|
89
|
+
return { status: 200, body: await identity.session(await caller()) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
44
92
|
switch (routeId) {
|
|
45
93
|
case 'GET /SASjsApi/drive/file': {
|
|
46
94
|
const file = await drive.read(String(query._filePath || ''));
|
|
@@ -94,5 +142,12 @@ export const HANDLED_ROUTES = [
|
|
|
94
142
|
'POST /SASjsApi/drive/folder',
|
|
95
143
|
'DELETE /SASjsApi/drive/folder',
|
|
96
144
|
'POST /SASjsApi/drive/rename',
|
|
97
|
-
'GET /SASjsApi/drive/filetree'
|
|
145
|
+
'GET /SASjsApi/drive/filetree',
|
|
146
|
+
'POST /SASjsApi/auth/token',
|
|
147
|
+
'POST /SASjsApi/auth/refresh',
|
|
148
|
+
'POST /SASjsApi/auth/logout',
|
|
149
|
+
'POST /SASLogon/login',
|
|
150
|
+
'POST /SASLogon/authorize',
|
|
151
|
+
'GET /SASLogon/logout',
|
|
152
|
+
'GET /SASjsApi/session'
|
|
98
153
|
];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity a browser build starts from, and the format it travels in.
|
|
3
|
+
*
|
|
4
|
+
* A shipped fixture is what makes a mock build work with no configuration: it
|
|
5
|
+
* is small, legible, and declared to be fake. Passwords are plain text because
|
|
6
|
+
* nothing verifies them beyond an equality check - there is no boundary here,
|
|
7
|
+
* and pretending otherwise by shipping a hash would suggest one.
|
|
8
|
+
*
|
|
9
|
+
* `format` is stamped the way the drive's snapshot is: a store written by an
|
|
10
|
+
* older build is refused with a named reason rather than misread.
|
|
11
|
+
*/
|
|
12
|
+
export declare const IDENTITY_FORMAT = 1;
|
|
13
|
+
export interface IdentityFixtureUser {
|
|
14
|
+
uid: string;
|
|
15
|
+
username: string;
|
|
16
|
+
displayName: string;
|
|
17
|
+
/** Plain text on purpose: a fixture, not a credential store. */
|
|
18
|
+
password: string;
|
|
19
|
+
isAdmin: boolean;
|
|
20
|
+
isActive: boolean;
|
|
21
|
+
needsToUpdatePassword: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface IdentityFixtureGroup {
|
|
24
|
+
uid: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
isActive: boolean;
|
|
28
|
+
/** Usernames of the members. */
|
|
29
|
+
users: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface IdentityFixturePermission {
|
|
32
|
+
uid: string;
|
|
33
|
+
path: string;
|
|
34
|
+
/** `Route` is the only kind the API defines. */
|
|
35
|
+
type: string;
|
|
36
|
+
/** `Grant` or `Deny`. */
|
|
37
|
+
setting: string;
|
|
38
|
+
/** The username the rule is assigned to, when it is not a group's. */
|
|
39
|
+
user?: string;
|
|
40
|
+
/** The group the rule is assigned to, when it is not a user's. */
|
|
41
|
+
group?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface IdentityFixtureClient {
|
|
44
|
+
clientId: string;
|
|
45
|
+
clientSecret: string;
|
|
46
|
+
/** Seconds an access token lives. */
|
|
47
|
+
accessTokenExpiration: number;
|
|
48
|
+
/** Seconds a refresh token lives. */
|
|
49
|
+
refreshTokenExpiration: number;
|
|
50
|
+
}
|
|
51
|
+
/** An authorization code a host should already accept, so a case can start mid-flow. */
|
|
52
|
+
export interface IdentityFixtureCode {
|
|
53
|
+
code: string;
|
|
54
|
+
username: string;
|
|
55
|
+
clientId: string;
|
|
56
|
+
}
|
|
57
|
+
/** A token a host should already accept, so a case can start signed in. */
|
|
58
|
+
export interface IdentityFixtureToken {
|
|
59
|
+
username: string;
|
|
60
|
+
clientId: string;
|
|
61
|
+
accessToken?: string;
|
|
62
|
+
refreshToken?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface IdentityFixture {
|
|
65
|
+
format: number;
|
|
66
|
+
users: IdentityFixtureUser[];
|
|
67
|
+
groups: IdentityFixtureGroup[];
|
|
68
|
+
permissions: IdentityFixturePermission[];
|
|
69
|
+
clients: IdentityFixtureClient[];
|
|
70
|
+
/** Tokens a host accepts before it has issued any. */
|
|
71
|
+
tokens?: IdentityFixtureToken[];
|
|
72
|
+
/** Authorization codes a host accepts before it has issued any. */
|
|
73
|
+
codes?: IdentityFixtureCode[];
|
|
74
|
+
}
|
|
75
|
+
export declare const DEFAULT_IDENTITY: IdentityFixture;
|
|
76
|
+
/**
|
|
77
|
+
* Parses a fixture a build wrote, refusing one this package cannot read.
|
|
78
|
+
*
|
|
79
|
+
* The checks name what is wrong and what to do about it: a fixture is hand-
|
|
80
|
+
* editable, so "invalid identity" would send the reader to the type rather than
|
|
81
|
+
* to the line.
|
|
82
|
+
*/
|
|
83
|
+
export declare const parseIdentity: (raw: unknown) => IdentityFixture;
|
|
84
|
+
//# sourceMappingURL=fixture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fixture.d.ts","sourceRoot":"","sources":["../../../src/identity/fixture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,eAAe,IAAI,CAAA;AAEhC,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;CAC/B;AAED,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,OAAO,CAAA;IACjB,gCAAgC;IAChC,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB;AAED,MAAM,WAAW,yBAAyB;IACxC,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAA;IACZ,yBAAyB;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,qCAAqC;IACrC,qBAAqB,EAAE,MAAM,CAAA;IAC7B,qCAAqC;IACrC,sBAAsB,EAAE,MAAM,CAAA;CAC/B;AAED,wFAAwF;AACxF,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,2EAA2E;AAC3E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,KAAK,EAAE,mBAAmB,EAAE,CAAA;IAC5B,MAAM,EAAE,oBAAoB,EAAE,CAAA;IAC9B,WAAW,EAAE,yBAAyB,EAAE,CAAA;IACxC,OAAO,EAAE,qBAAqB,EAAE,CAAA;IAChC,sDAAsD;IACtD,MAAM,CAAC,EAAE,oBAAoB,EAAE,CAAA;IAC/B,mEAAmE;IACnE,KAAK,CAAC,EAAE,mBAAmB,EAAE,CAAA;CAC9B;AAID,eAAO,MAAM,gBAAgB,EAAE,eA6E9B,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GAAI,KAAK,OAAO,KAAG,eA2B5C,CAAA"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity a browser build starts from, and the format it travels in.
|
|
3
|
+
*
|
|
4
|
+
* A shipped fixture is what makes a mock build work with no configuration: it
|
|
5
|
+
* is small, legible, and declared to be fake. Passwords are plain text because
|
|
6
|
+
* nothing verifies them beyond an equality check - there is no boundary here,
|
|
7
|
+
* and pretending otherwise by shipping a hash would suggest one.
|
|
8
|
+
*
|
|
9
|
+
* `format` is stamped the way the drive's snapshot is: a store written by an
|
|
10
|
+
* older build is refused with a named reason rather than misread.
|
|
11
|
+
*/
|
|
12
|
+
export const IDENTITY_FORMAT = 1;
|
|
13
|
+
const SECONDS_IN_A_DAY = 86400;
|
|
14
|
+
export const DEFAULT_IDENTITY = {
|
|
15
|
+
format: IDENTITY_FORMAT,
|
|
16
|
+
users: [
|
|
17
|
+
{
|
|
18
|
+
uid: 'user-admin',
|
|
19
|
+
username: 'admin',
|
|
20
|
+
displayName: 'Demo Admin',
|
|
21
|
+
// long enough to pass the API's own password rule (six characters), so a
|
|
22
|
+
// demo can sign in against a real server as well as a mock
|
|
23
|
+
password: 'admin123',
|
|
24
|
+
isAdmin: true,
|
|
25
|
+
isActive: true,
|
|
26
|
+
needsToUpdatePassword: false
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
uid: 'user-restricted',
|
|
30
|
+
username: 'user2',
|
|
31
|
+
displayName: 'Demo User',
|
|
32
|
+
password: 'user123',
|
|
33
|
+
isAdmin: false,
|
|
34
|
+
isActive: true,
|
|
35
|
+
needsToUpdatePassword: false
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
groups: [
|
|
39
|
+
{
|
|
40
|
+
uid: 'group-data-controller',
|
|
41
|
+
name: 'DCGroup',
|
|
42
|
+
description: 'Data Controller users',
|
|
43
|
+
isActive: true,
|
|
44
|
+
users: ['user2']
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
uid: 'group-admins',
|
|
48
|
+
name: 'Admins',
|
|
49
|
+
description: 'Everything the API offers',
|
|
50
|
+
isActive: true,
|
|
51
|
+
users: ['admin']
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
permissions: [
|
|
55
|
+
{
|
|
56
|
+
uid: 'rule-routes',
|
|
57
|
+
path: '/SASjsApi/info',
|
|
58
|
+
type: 'Route',
|
|
59
|
+
setting: 'Grant',
|
|
60
|
+
group: 'group-data-controller'
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
uid: 'rule-execute',
|
|
64
|
+
path: '/SASjsApi/stp/execute',
|
|
65
|
+
type: 'Route',
|
|
66
|
+
setting: 'Grant',
|
|
67
|
+
group: 'group-data-controller'
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
uid: 'rule-code',
|
|
71
|
+
path: '/SASjsApi/code/execute',
|
|
72
|
+
type: 'Route',
|
|
73
|
+
setting: 'Deny',
|
|
74
|
+
user: 'user-restricted'
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
clients: [
|
|
78
|
+
{
|
|
79
|
+
clientId: 'web_app',
|
|
80
|
+
clientSecret: 'web_app',
|
|
81
|
+
accessTokenExpiration: SECONDS_IN_A_DAY,
|
|
82
|
+
refreshTokenExpiration: SECONDS_IN_A_DAY * 30
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
clientId: 'sasjs',
|
|
86
|
+
clientSecret: 'sasjs',
|
|
87
|
+
accessTokenExpiration: 3600,
|
|
88
|
+
refreshTokenExpiration: SECONDS_IN_A_DAY
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Parses a fixture a build wrote, refusing one this package cannot read.
|
|
94
|
+
*
|
|
95
|
+
* The checks name what is wrong and what to do about it: a fixture is hand-
|
|
96
|
+
* editable, so "invalid identity" would send the reader to the type rather than
|
|
97
|
+
* to the line.
|
|
98
|
+
*/
|
|
99
|
+
export const parseIdentity = (raw) => {
|
|
100
|
+
const fixture = raw;
|
|
101
|
+
if (!fixture || typeof fixture !== 'object')
|
|
102
|
+
throw new Error('The identity fixture is not an object.');
|
|
103
|
+
if (fixture.format !== IDENTITY_FORMAT)
|
|
104
|
+
throw new Error(`The identity fixture states format ${String(fixture.format)}, and this build reads format ${IDENTITY_FORMAT}. Rebuild the site, or replace mock-identity.json with one this version wrote.`);
|
|
105
|
+
for (const key of ['users', 'groups', 'permissions', 'clients'])
|
|
106
|
+
if (!Array.isArray(fixture[key]))
|
|
107
|
+
throw new Error(`The identity fixture has no \`${key}\` array.`);
|
|
108
|
+
for (const token of fixture.tokens || [])
|
|
109
|
+
if (!token.accessToken || !token.refreshToken)
|
|
110
|
+
throw new Error(`The identity fixture declares a half pair for ${token.username}/${token.clientId}: a seeded token needs both an accessToken and a refreshToken, because a token only authenticates while the subject's pair holds it.`);
|
|
111
|
+
if (!fixture.users.some((user) => user.isAdmin))
|
|
112
|
+
throw new Error('The identity fixture declares no administrator: a demo with nobody able to administer it cannot exercise the admin routes.');
|
|
113
|
+
return fixture;
|
|
114
|
+
};
|