@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,268 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IDENTITY_CORPUS = exports.DEACTIVATED = exports.SIGNED_IN = void 0;
|
|
4
|
+
const fixture_js_1 = require("../identity/fixture.js");
|
|
5
|
+
/** The default fixture, plus a caller that is already signed in. */
|
|
6
|
+
exports.SIGNED_IN = {
|
|
7
|
+
...fixture_js_1.DEFAULT_IDENTITY,
|
|
8
|
+
tokens: [
|
|
9
|
+
{
|
|
10
|
+
username: 'user2',
|
|
11
|
+
clientId: 'web_app',
|
|
12
|
+
accessToken: 'seed-access',
|
|
13
|
+
refreshToken: 'seed-refresh'
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
username: 'admin',
|
|
17
|
+
clientId: 'removed_client',
|
|
18
|
+
accessToken: 'seed-access-orphan',
|
|
19
|
+
refreshToken: 'seed-refresh-orphan'
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
codes: [
|
|
23
|
+
{ code: 'seed-code', username: 'user2', clientId: 'web_app' },
|
|
24
|
+
{ code: 'seed-orphan-code', username: 'user2', clientId: 'removed_client' },
|
|
25
|
+
// the same shape with a pair already issued: the exchange must still refuse,
|
|
26
|
+
// rather than handing back tokens that belong to a client that is gone
|
|
27
|
+
{
|
|
28
|
+
code: 'seed-orphan-pair-code',
|
|
29
|
+
username: 'admin',
|
|
30
|
+
clientId: 'removed_client'
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
};
|
|
34
|
+
/** The same identity, with the restricted user switched off. */
|
|
35
|
+
exports.DEACTIVATED = {
|
|
36
|
+
...exports.SIGNED_IN,
|
|
37
|
+
users: exports.SIGNED_IN.users.map((user) => user.username === 'user2' ? { ...user, isActive: false } : user)
|
|
38
|
+
};
|
|
39
|
+
/** The user the seeded session belongs to, without the host's own identifier. */
|
|
40
|
+
const user2 = {
|
|
41
|
+
username: 'user2',
|
|
42
|
+
displayName: 'Demo User',
|
|
43
|
+
isAdmin: false,
|
|
44
|
+
needsToUpdatePassword: false
|
|
45
|
+
};
|
|
46
|
+
exports.IDENTITY_CORPUS = [
|
|
47
|
+
{
|
|
48
|
+
id: 'login',
|
|
49
|
+
route: 'POST /SASLogon/login',
|
|
50
|
+
description: 'signs a seeded user in',
|
|
51
|
+
request: { body: { username: 'admin', password: 'admin123' } },
|
|
52
|
+
expect: {
|
|
53
|
+
status: 200,
|
|
54
|
+
partial: {
|
|
55
|
+
loggedIn: true,
|
|
56
|
+
user: {
|
|
57
|
+
username: 'admin',
|
|
58
|
+
displayName: 'Demo Admin',
|
|
59
|
+
isAdmin: true,
|
|
60
|
+
needsToUpdatePassword: false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'login-missing-username',
|
|
67
|
+
route: 'POST /SASLogon/login',
|
|
68
|
+
description: 'refuses a sign-in with no username as 400, naming the field',
|
|
69
|
+
request: { body: { password: 'admin123' } },
|
|
70
|
+
expect: { status: 400, error: '"username" is required' }
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'login-deactivated',
|
|
74
|
+
route: 'POST /SASLogon/login',
|
|
75
|
+
description: 'refuses a deactivated account, naming it',
|
|
76
|
+
identity: exports.DEACTIVATED,
|
|
77
|
+
request: { body: { username: 'user2', password: 'user123' } },
|
|
78
|
+
expect: { status: 401, error: 'This account is not active.' }
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'login-wrong-password',
|
|
82
|
+
route: 'POST /SASLogon/login',
|
|
83
|
+
description: 'refuses a wrong password as 401, naming it',
|
|
84
|
+
request: { body: { username: 'admin', password: 'wrong123' } },
|
|
85
|
+
expect: { status: 401, error: 'Invalid Password.' }
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'login-unknown-user',
|
|
89
|
+
route: 'POST /SASLogon/login',
|
|
90
|
+
description: 'refuses an unknown username as 401, naming it',
|
|
91
|
+
request: { body: { username: 'nobody', password: 'admin123' } },
|
|
92
|
+
expect: { status: 401, error: 'Username is not found.' }
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'authorize',
|
|
96
|
+
route: 'POST /SASLogon/authorize',
|
|
97
|
+
description: 'issues a code to a signed-in caller',
|
|
98
|
+
identity: exports.SIGNED_IN,
|
|
99
|
+
request: {
|
|
100
|
+
token: 'seed-access',
|
|
101
|
+
body: { clientId: 'web_app' }
|
|
102
|
+
},
|
|
103
|
+
expect: { status: 200, keys: ['code'] }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'authorize-missing-client',
|
|
107
|
+
route: 'POST /SASLogon/authorize',
|
|
108
|
+
description: 'refuses an authorize with no clientId as 400, naming the field',
|
|
109
|
+
identity: exports.SIGNED_IN,
|
|
110
|
+
request: { token: 'seed-access', body: {} },
|
|
111
|
+
expect: { status: 400, error: '"clientId" is required' }
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'authorize-without-session',
|
|
115
|
+
route: 'POST /SASLogon/authorize',
|
|
116
|
+
description: 'refuses with 401 when no caller is signed in',
|
|
117
|
+
request: { body: { clientId: 'web_app' } },
|
|
118
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'authorize-unknown-client',
|
|
122
|
+
route: 'POST /SASLogon/authorize',
|
|
123
|
+
description: 'refuses an unknown client with 403',
|
|
124
|
+
identity: exports.SIGNED_IN,
|
|
125
|
+
request: { token: 'seed-access', body: { clientId: 'nope' } },
|
|
126
|
+
expect: { status: 403, error: 'Invalid clientId.' }
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'exchange-code',
|
|
130
|
+
route: 'POST /SASjsApi/auth/token',
|
|
131
|
+
description: 'exchanges a code for a token pair',
|
|
132
|
+
identity: exports.SIGNED_IN,
|
|
133
|
+
request: { body: { clientId: 'web_app', code: 'seed-code' } },
|
|
134
|
+
expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'exchange-missing-client',
|
|
138
|
+
route: 'POST /SASjsApi/auth/token',
|
|
139
|
+
description: 'refuses an exchange with no clientId as 400, naming the field',
|
|
140
|
+
identity: exports.SIGNED_IN,
|
|
141
|
+
request: { body: { code: 'seed-code' } },
|
|
142
|
+
expect: { status: 400, error: '"clientId" is required' }
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'exchange-code-unknown',
|
|
146
|
+
route: 'POST /SASjsApi/auth/token',
|
|
147
|
+
description: 'refuses a code the host never issued',
|
|
148
|
+
request: { body: { clientId: 'web_app', code: 'not-a-code' } },
|
|
149
|
+
expect: { status: 401, error: 'Invalid Auth Code' }
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'exchange-code-other-client',
|
|
153
|
+
route: 'POST /SASjsApi/auth/token',
|
|
154
|
+
description: 'refuses a code presented for another client, as a bad code',
|
|
155
|
+
identity: exports.SIGNED_IN,
|
|
156
|
+
request: { body: { clientId: 'sasjs', code: 'seed-code' } },
|
|
157
|
+
expect: { status: 401, error: 'Invalid Auth Code' }
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'exchange-code-client-gone',
|
|
161
|
+
route: 'POST /SASjsApi/auth/token',
|
|
162
|
+
description: 'refuses a valid code whose client no longer exists',
|
|
163
|
+
identity: exports.SIGNED_IN,
|
|
164
|
+
request: { body: { clientId: 'removed_client', code: 'seed-orphan-code' } },
|
|
165
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: 'exchange-code-client-gone-with-pair',
|
|
169
|
+
route: 'POST /SASjsApi/auth/token',
|
|
170
|
+
description: 'refuses an exchange whose client is gone even when a pair was issued before it was deleted',
|
|
171
|
+
identity: exports.SIGNED_IN,
|
|
172
|
+
request: {
|
|
173
|
+
body: { clientId: 'removed_client', code: 'seed-orphan-pair-code' }
|
|
174
|
+
},
|
|
175
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'exchange-code-deactivated',
|
|
179
|
+
route: 'POST /SASjsApi/auth/token',
|
|
180
|
+
description: 'refuses an exchange for an account that has been deactivated',
|
|
181
|
+
identity: exports.DEACTIVATED,
|
|
182
|
+
request: { body: { clientId: 'web_app', code: 'seed-code' } },
|
|
183
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: 'refresh',
|
|
187
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
188
|
+
description: 'mints a new pair from a refresh token',
|
|
189
|
+
identity: exports.SIGNED_IN,
|
|
190
|
+
request: { token: 'seed-refresh' },
|
|
191
|
+
expect: { status: 200, keys: ['accessToken', 'refreshToken'] }
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: 'refresh-invalid',
|
|
195
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
196
|
+
description: 'refuses a refresh token it did not issue',
|
|
197
|
+
request: { token: 'not-a-token' },
|
|
198
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'refresh-without-token',
|
|
202
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
203
|
+
description: 'refuses a refresh with no token at all',
|
|
204
|
+
request: {},
|
|
205
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'refresh-client-gone',
|
|
209
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
210
|
+
description: 'refuses a refresh whose client no longer exists',
|
|
211
|
+
identity: exports.SIGNED_IN,
|
|
212
|
+
request: { token: 'seed-refresh-orphan' },
|
|
213
|
+
expect: { status: 401, error: 'Invalid clientId.' }
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
id: 'refresh-deactivated',
|
|
217
|
+
route: 'POST /SASjsApi/auth/refresh',
|
|
218
|
+
description: 'refuses a refresh for an account that has been deactivated, as the server does on every request',
|
|
219
|
+
identity: exports.DEACTIVATED,
|
|
220
|
+
request: { token: 'seed-refresh' },
|
|
221
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
id: 'logout',
|
|
225
|
+
route: 'POST /SASjsApi/auth/logout',
|
|
226
|
+
description: 'revokes the caller pair and answers 204',
|
|
227
|
+
identity: exports.SIGNED_IN,
|
|
228
|
+
request: { token: 'seed-access' },
|
|
229
|
+
expect: { status: 204 }
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: 'logout-without-token',
|
|
233
|
+
route: 'POST /SASjsApi/auth/logout',
|
|
234
|
+
description: 'refuses a logout with no valid token',
|
|
235
|
+
request: {},
|
|
236
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: 'session',
|
|
240
|
+
route: 'GET /SASjsApi/session',
|
|
241
|
+
description: 'reports the caller the token names',
|
|
242
|
+
identity: exports.SIGNED_IN,
|
|
243
|
+
request: { token: 'seed-access' },
|
|
244
|
+
expect: { status: 200, partial: user2 }
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'session-deactivated',
|
|
248
|
+
route: 'GET /SASjsApi/session',
|
|
249
|
+
description: 'stops resolving a token whose account has been deactivated',
|
|
250
|
+
identity: exports.DEACTIVATED,
|
|
251
|
+
request: { token: 'seed-access' },
|
|
252
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'session-without-token',
|
|
256
|
+
route: 'GET /SASjsApi/session',
|
|
257
|
+
description: 'refuses the session read with no caller',
|
|
258
|
+
request: {},
|
|
259
|
+
expect: { status: 401, error: 'Unauthorized' }
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'sign-out',
|
|
263
|
+
route: 'GET /SASLogon/logout',
|
|
264
|
+
description: 'answers the body the browser sign-out returns',
|
|
265
|
+
request: {},
|
|
266
|
+
expect: { status: 200, contents: 'OK!' }
|
|
267
|
+
}
|
|
268
|
+
];
|
|
@@ -4,10 +4,19 @@ exports.runCase = exports.servicesFor = void 0;
|
|
|
4
4
|
const dispatch_js_1 = require("../host/dispatch.js");
|
|
5
5
|
const service_js_1 = require("../drive/service.js");
|
|
6
6
|
const memory_store_js_1 = require("../drive/memory-store.js");
|
|
7
|
+
const service_js_2 = require("../identity/service.js");
|
|
8
|
+
const memory_store_js_2 = require("../identity/memory-store.js");
|
|
9
|
+
const fixture_js_1 = require("../identity/fixture.js");
|
|
7
10
|
const types_js_1 = require("../types.js");
|
|
8
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Services over fresh stores, seeded with the case's files and identity.
|
|
13
|
+
*
|
|
14
|
+
* The identity store is built for every case, drive or not, so a case that starts
|
|
15
|
+
* mid-flow can declare the tokens and codes it begins with.
|
|
16
|
+
*/
|
|
9
17
|
const servicesFor = (testCase) => ({
|
|
10
|
-
drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {}))
|
|
18
|
+
drive: (0, service_js_1.createDriveService)(new memory_store_js_1.MemoryDriveStore(testCase.seed || {})),
|
|
19
|
+
identity: (0, service_js_2.createIdentityService)(new memory_store_js_2.MemoryIdentityStore(testCase.identity || fixture_js_1.DEFAULT_IDENTITY))
|
|
11
20
|
});
|
|
12
21
|
exports.servicesFor = servicesFor;
|
|
13
22
|
/**
|
|
@@ -42,8 +42,56 @@ const dispatch = async (services, routeId, request = {}) => {
|
|
|
42
42
|
if (!route)
|
|
43
43
|
throw new types_js_1.ApiError(404, `No such operation: ${routeId}`);
|
|
44
44
|
validate(route, request);
|
|
45
|
-
const { drive } = services;
|
|
45
|
+
const { drive, identity } = services;
|
|
46
46
|
const query = request.query || {};
|
|
47
|
+
const body = request.body || {};
|
|
48
|
+
/** The caller, from the request's own subject or the token it carried. */
|
|
49
|
+
const caller = async () => {
|
|
50
|
+
if (request.subject)
|
|
51
|
+
return request.subject;
|
|
52
|
+
return identity?.authenticate(request.token);
|
|
53
|
+
};
|
|
54
|
+
if (routeId.startsWith('POST /SASjsApi/auth/') ||
|
|
55
|
+
routeId.startsWith('POST /SASLogon/') ||
|
|
56
|
+
routeId === 'GET /SASLogon/logout' ||
|
|
57
|
+
routeId === 'GET /SASjsApi/session') {
|
|
58
|
+
if (!identity)
|
|
59
|
+
throw new types_js_1.ApiError(501, `This host has no identity service, so it cannot answer ${routeId}.`);
|
|
60
|
+
switch (routeId) {
|
|
61
|
+
case 'POST /SASjsApi/auth/token':
|
|
62
|
+
return {
|
|
63
|
+
status: 200,
|
|
64
|
+
body: await identity.token({
|
|
65
|
+
clientId: String(body.clientId ?? ''),
|
|
66
|
+
code: String(body.code ?? '')
|
|
67
|
+
})
|
|
68
|
+
};
|
|
69
|
+
case 'POST /SASjsApi/auth/refresh':
|
|
70
|
+
return { status: 200, body: await identity.refresh(request.token) };
|
|
71
|
+
case 'POST /SASjsApi/auth/logout':
|
|
72
|
+
await identity.logout(await caller());
|
|
73
|
+
return { status: 204 };
|
|
74
|
+
case 'POST /SASLogon/login':
|
|
75
|
+
return {
|
|
76
|
+
status: 200,
|
|
77
|
+
body: await identity.login({
|
|
78
|
+
username: String(body.username ?? ''),
|
|
79
|
+
password: String(body.password ?? '')
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
case 'POST /SASLogon/authorize':
|
|
83
|
+
return {
|
|
84
|
+
status: 200,
|
|
85
|
+
body: {
|
|
86
|
+
code: await identity.authorize(await caller(), String(body.clientId ?? ''))
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
case 'GET /SASLogon/logout':
|
|
90
|
+
return { status: 200, contents: await identity.logoutSession() };
|
|
91
|
+
case 'GET /SASjsApi/session':
|
|
92
|
+
return { status: 200, body: await identity.session(await caller()) };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
47
95
|
switch (routeId) {
|
|
48
96
|
case 'GET /SASjsApi/drive/file': {
|
|
49
97
|
const file = await drive.read(String(query._filePath || ''));
|
|
@@ -98,5 +146,12 @@ exports.HANDLED_ROUTES = [
|
|
|
98
146
|
'POST /SASjsApi/drive/folder',
|
|
99
147
|
'DELETE /SASjsApi/drive/folder',
|
|
100
148
|
'POST /SASjsApi/drive/rename',
|
|
101
|
-
'GET /SASjsApi/drive/filetree'
|
|
149
|
+
'GET /SASjsApi/drive/filetree',
|
|
150
|
+
'POST /SASjsApi/auth/token',
|
|
151
|
+
'POST /SASjsApi/auth/refresh',
|
|
152
|
+
'POST /SASjsApi/auth/logout',
|
|
153
|
+
'POST /SASLogon/login',
|
|
154
|
+
'POST /SASLogon/authorize',
|
|
155
|
+
'GET /SASLogon/logout',
|
|
156
|
+
'GET /SASjsApi/session'
|
|
102
157
|
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The identity a browser build starts from, and the format it travels in.
|
|
4
|
+
*
|
|
5
|
+
* A shipped fixture is what makes a mock build work with no configuration: it
|
|
6
|
+
* is small, legible, and declared to be fake. Passwords are plain text because
|
|
7
|
+
* nothing verifies them beyond an equality check - there is no boundary here,
|
|
8
|
+
* and pretending otherwise by shipping a hash would suggest one.
|
|
9
|
+
*
|
|
10
|
+
* `format` is stamped the way the drive's snapshot is: a store written by an
|
|
11
|
+
* older build is refused with a named reason rather than misread.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.parseIdentity = exports.DEFAULT_IDENTITY = exports.IDENTITY_FORMAT = void 0;
|
|
15
|
+
exports.IDENTITY_FORMAT = 1;
|
|
16
|
+
const SECONDS_IN_A_DAY = 86400;
|
|
17
|
+
exports.DEFAULT_IDENTITY = {
|
|
18
|
+
format: exports.IDENTITY_FORMAT,
|
|
19
|
+
users: [
|
|
20
|
+
{
|
|
21
|
+
uid: 'user-admin',
|
|
22
|
+
username: 'admin',
|
|
23
|
+
displayName: 'Demo Admin',
|
|
24
|
+
// long enough to pass the API's own password rule (six characters), so a
|
|
25
|
+
// demo can sign in against a real server as well as a mock
|
|
26
|
+
password: 'admin123',
|
|
27
|
+
isAdmin: true,
|
|
28
|
+
isActive: true,
|
|
29
|
+
needsToUpdatePassword: false
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
uid: 'user-restricted',
|
|
33
|
+
username: 'user2',
|
|
34
|
+
displayName: 'Demo User',
|
|
35
|
+
password: 'user123',
|
|
36
|
+
isAdmin: false,
|
|
37
|
+
isActive: true,
|
|
38
|
+
needsToUpdatePassword: false
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
groups: [
|
|
42
|
+
{
|
|
43
|
+
uid: 'group-data-controller',
|
|
44
|
+
name: 'DCGroup',
|
|
45
|
+
description: 'Data Controller users',
|
|
46
|
+
isActive: true,
|
|
47
|
+
users: ['user2']
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
uid: 'group-admins',
|
|
51
|
+
name: 'Admins',
|
|
52
|
+
description: 'Everything the API offers',
|
|
53
|
+
isActive: true,
|
|
54
|
+
users: ['admin']
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
permissions: [
|
|
58
|
+
{
|
|
59
|
+
uid: 'rule-routes',
|
|
60
|
+
path: '/SASjsApi/info',
|
|
61
|
+
type: 'Route',
|
|
62
|
+
setting: 'Grant',
|
|
63
|
+
group: 'group-data-controller'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
uid: 'rule-execute',
|
|
67
|
+
path: '/SASjsApi/stp/execute',
|
|
68
|
+
type: 'Route',
|
|
69
|
+
setting: 'Grant',
|
|
70
|
+
group: 'group-data-controller'
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
uid: 'rule-code',
|
|
74
|
+
path: '/SASjsApi/code/execute',
|
|
75
|
+
type: 'Route',
|
|
76
|
+
setting: 'Deny',
|
|
77
|
+
user: 'user-restricted'
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
clients: [
|
|
81
|
+
{
|
|
82
|
+
clientId: 'web_app',
|
|
83
|
+
clientSecret: 'web_app',
|
|
84
|
+
accessTokenExpiration: SECONDS_IN_A_DAY,
|
|
85
|
+
refreshTokenExpiration: SECONDS_IN_A_DAY * 30
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
clientId: 'sasjs',
|
|
89
|
+
clientSecret: 'sasjs',
|
|
90
|
+
accessTokenExpiration: 3600,
|
|
91
|
+
refreshTokenExpiration: SECONDS_IN_A_DAY
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Parses a fixture a build wrote, refusing one this package cannot read.
|
|
97
|
+
*
|
|
98
|
+
* The checks name what is wrong and what to do about it: a fixture is hand-
|
|
99
|
+
* editable, so "invalid identity" would send the reader to the type rather than
|
|
100
|
+
* to the line.
|
|
101
|
+
*/
|
|
102
|
+
const parseIdentity = (raw) => {
|
|
103
|
+
const fixture = raw;
|
|
104
|
+
if (!fixture || typeof fixture !== 'object')
|
|
105
|
+
throw new Error('The identity fixture is not an object.');
|
|
106
|
+
if (fixture.format !== exports.IDENTITY_FORMAT)
|
|
107
|
+
throw new Error(`The identity fixture states format ${String(fixture.format)}, and this build reads format ${exports.IDENTITY_FORMAT}. Rebuild the site, or replace mock-identity.json with one this version wrote.`);
|
|
108
|
+
for (const key of ['users', 'groups', 'permissions', 'clients'])
|
|
109
|
+
if (!Array.isArray(fixture[key]))
|
|
110
|
+
throw new Error(`The identity fixture has no \`${key}\` array.`);
|
|
111
|
+
for (const token of fixture.tokens || [])
|
|
112
|
+
if (!token.accessToken || !token.refreshToken)
|
|
113
|
+
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.`);
|
|
114
|
+
if (!fixture.users.some((user) => user.isAdmin))
|
|
115
|
+
throw new Error('The identity fixture declares no administrator: a demo with nobody able to administer it cannot exercise the admin routes.');
|
|
116
|
+
return fixture;
|
|
117
|
+
};
|
|
118
|
+
exports.parseIdentity = parseIdentity;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryIdentityStore = void 0;
|
|
4
|
+
const fixture_js_1 = require("./fixture.js");
|
|
5
|
+
const types_js_1 = require("../types.js");
|
|
6
|
+
const keyOf = (subject) => `${subject.userId}|${subject.clientId}`;
|
|
7
|
+
/**
|
|
8
|
+
* Opaque identity strings, from the platform's cryptographic source.
|
|
9
|
+
*
|
|
10
|
+
* These strings are credentials: whoever holds one is the caller it names. A
|
|
11
|
+
* counter or `Math.random` is a sequence a reader can reproduce, so it would
|
|
12
|
+
* let one caller name another - the store is small and demo-shaped, but a token
|
|
13
|
+
* that can be guessed is not a demo quirk, it is an authentication bypass.
|
|
14
|
+
*
|
|
15
|
+
* `globalThis.crypto` is present in every browser and in Node from 19, which is
|
|
16
|
+
* the same reach the rest of this package has (no node imports, so the browser
|
|
17
|
+
* build bundles it unchanged).
|
|
18
|
+
*/
|
|
19
|
+
const randomHex = (bytes) => {
|
|
20
|
+
const source = globalThis.crypto;
|
|
21
|
+
if (!source)
|
|
22
|
+
throw new types_js_1.ApiError(500, 'This host has no Web Crypto (`globalThis.crypto`), so it cannot mint an unguessable identity string.');
|
|
23
|
+
const buffer = new Uint8Array(bytes);
|
|
24
|
+
source.getRandomValues(buffer);
|
|
25
|
+
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* How long a code buys tokens, in seconds.
|
|
29
|
+
*
|
|
30
|
+
* The same lifetime `sasjs/server` signs into its auth code JWT, so a code a
|
|
31
|
+
* mock issues and one a server issues age out together.
|
|
32
|
+
*/
|
|
33
|
+
const CODE_LIFETIME_SECONDS = 30;
|
|
34
|
+
const live = (issued, now) => issued && (issued.expiresAt === undefined || issued.expiresAt > now)
|
|
35
|
+
? issued.subject
|
|
36
|
+
: undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a token minted for one subject names the same user and client.
|
|
39
|
+
*
|
|
40
|
+
* The fixture can declare a token that outlives the map it is seeded into, so
|
|
41
|
+
* the read paths compare rather than trust the reference.
|
|
42
|
+
*/
|
|
43
|
+
class MemoryIdentityStore {
|
|
44
|
+
constructor(fixture = fixture_js_1.DEFAULT_IDENTITY) {
|
|
45
|
+
this.users = [];
|
|
46
|
+
this.passwords = new Map();
|
|
47
|
+
this.clients = new Map();
|
|
48
|
+
this.pairs = new Map();
|
|
49
|
+
this.access = new Map();
|
|
50
|
+
this.refresh = new Map();
|
|
51
|
+
this.codes = new Map();
|
|
52
|
+
this.claimed = new Set();
|
|
53
|
+
if (fixture.format !== fixture_js_1.IDENTITY_FORMAT)
|
|
54
|
+
throw new types_js_1.ApiError(500, `The identity fixture states format ${String(fixture.format)}; this store reads ${fixture_js_1.IDENTITY_FORMAT}.`);
|
|
55
|
+
for (const user of fixture.users) {
|
|
56
|
+
this.users.push({
|
|
57
|
+
uid: user.uid,
|
|
58
|
+
username: user.username,
|
|
59
|
+
displayName: user.displayName,
|
|
60
|
+
isAdmin: user.isAdmin,
|
|
61
|
+
isActive: user.isActive,
|
|
62
|
+
needsToUpdatePassword: user.needsToUpdatePassword
|
|
63
|
+
});
|
|
64
|
+
this.passwords.set(user.uid, user.password);
|
|
65
|
+
}
|
|
66
|
+
for (const client of fixture.clients)
|
|
67
|
+
this.clients.set(client.clientId, {
|
|
68
|
+
clientId: client.clientId,
|
|
69
|
+
accessTokenExpiration: client.accessTokenExpiration,
|
|
70
|
+
refreshTokenExpiration: client.refreshTokenExpiration
|
|
71
|
+
});
|
|
72
|
+
for (const token of fixture.tokens || []) {
|
|
73
|
+
const user = this.users.find((candidate) => candidate.username === token.username);
|
|
74
|
+
if (!user)
|
|
75
|
+
continue;
|
|
76
|
+
const subject = { userId: user.uid, clientId: token.clientId };
|
|
77
|
+
if (token.accessToken)
|
|
78
|
+
this.access.set(token.accessToken, { subject });
|
|
79
|
+
if (token.refreshToken)
|
|
80
|
+
this.refresh.set(token.refreshToken, { subject });
|
|
81
|
+
// a seeded token is the subject's live pair, which is what makes it
|
|
82
|
+
// authenticate: the read paths check the pair, not the token alone
|
|
83
|
+
if (token.accessToken && token.refreshToken)
|
|
84
|
+
this.pairs.set(keyOf(subject), {
|
|
85
|
+
accessToken: token.accessToken,
|
|
86
|
+
refreshToken: token.refreshToken
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
for (const code of fixture.codes || []) {
|
|
90
|
+
const user = this.users.find((candidate) => candidate.username === code.username);
|
|
91
|
+
if (!user)
|
|
92
|
+
continue;
|
|
93
|
+
this.codes.set(code.code, {
|
|
94
|
+
subject: { userId: user.uid, clientId: code.clientId }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async userByName(username) {
|
|
99
|
+
return this.users.find((user) => user.username === username);
|
|
100
|
+
}
|
|
101
|
+
async userById(uid) {
|
|
102
|
+
return this.users.find((user) => user.uid === uid);
|
|
103
|
+
}
|
|
104
|
+
/** A fixture holds the password in clear, so the check is an equality. */
|
|
105
|
+
async passwordMatches(user, password) {
|
|
106
|
+
return this.passwords.get(user.uid) === password;
|
|
107
|
+
}
|
|
108
|
+
async client(clientId) {
|
|
109
|
+
return this.clients.get(clientId);
|
|
110
|
+
}
|
|
111
|
+
async issue(subject, client) {
|
|
112
|
+
// The lifetimes are the client's, recorded against the instant of issue: a
|
|
113
|
+
// token's expiry is a fact about this pair, so the read paths can answer
|
|
114
|
+
// "invalid or expired" rather than carrying a number nothing compares.
|
|
115
|
+
const issuedAt = Date.now();
|
|
116
|
+
const pair = {
|
|
117
|
+
accessToken: `access-${randomHex(16)}`,
|
|
118
|
+
refreshToken: `refresh-${randomHex(16)}`
|
|
119
|
+
};
|
|
120
|
+
this.access.set(pair.accessToken, {
|
|
121
|
+
subject,
|
|
122
|
+
expiresAt: issuedAt + client.accessTokenExpiration * 1000
|
|
123
|
+
});
|
|
124
|
+
this.refresh.set(pair.refreshToken, {
|
|
125
|
+
subject,
|
|
126
|
+
expiresAt: issuedAt + client.refreshTokenExpiration * 1000
|
|
127
|
+
});
|
|
128
|
+
return pair;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A token names its subject only while that subject's CURRENT pair holds it.
|
|
132
|
+
* The server verifies a token against the pair saved for the user and client,
|
|
133
|
+
* so a logged-out or rotated token stops working there too; without the same
|
|
134
|
+
* check here, the browser host would honour a token the server had revoked.
|
|
135
|
+
*/
|
|
136
|
+
async readAccess(token) {
|
|
137
|
+
return this.current(token, this.access, 'accessToken');
|
|
138
|
+
}
|
|
139
|
+
async readRefresh(token) {
|
|
140
|
+
return this.current(token, this.refresh, 'refreshToken');
|
|
141
|
+
}
|
|
142
|
+
current(token, issued, kind) {
|
|
143
|
+
const entry = issued.get(token);
|
|
144
|
+
if (!entry)
|
|
145
|
+
return undefined;
|
|
146
|
+
const subject = live(entry, Date.now());
|
|
147
|
+
if (!subject)
|
|
148
|
+
return undefined;
|
|
149
|
+
const pair = this.pairs.get(keyOf(subject));
|
|
150
|
+
return pair && pair[kind] === token ? subject : undefined;
|
|
151
|
+
}
|
|
152
|
+
async pairFor(subject) {
|
|
153
|
+
return this.pairs.get(keyOf(subject));
|
|
154
|
+
}
|
|
155
|
+
async savePair(subject, pair) {
|
|
156
|
+
this.pairs.set(keyOf(subject), pair);
|
|
157
|
+
}
|
|
158
|
+
async deletePair(subject) {
|
|
159
|
+
this.pairs.delete(keyOf(subject));
|
|
160
|
+
}
|
|
161
|
+
async issueCode(subject) {
|
|
162
|
+
const code = `code-${randomHex(16)}`;
|
|
163
|
+
this.codes.set(code, {
|
|
164
|
+
subject,
|
|
165
|
+
expiresAt: Date.now() + CODE_LIFETIME_SECONDS * 1000
|
|
166
|
+
});
|
|
167
|
+
return code;
|
|
168
|
+
}
|
|
169
|
+
async readCode(code) {
|
|
170
|
+
return live(this.codes.get(code), Date.now());
|
|
171
|
+
}
|
|
172
|
+
async claimCode(code) {
|
|
173
|
+
if (this.claimed.has(code) || !this.codes.has(code))
|
|
174
|
+
return false;
|
|
175
|
+
this.claimed.add(code);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
exports.MemoryIdentityStore = MemoryIdentityStore;
|