@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.
Files changed (49) hide show
  1. package/README.md +28 -8
  2. package/build/browser.js +788 -4
  3. package/build/cjs/corpus/identity.js +268 -0
  4. package/build/cjs/corpus/runner.js +11 -2
  5. package/build/cjs/host/dispatch.js +57 -2
  6. package/build/cjs/identity/fixture.js +118 -0
  7. package/build/cjs/identity/memory-store.js +179 -0
  8. package/build/cjs/identity/service.js +142 -0
  9. package/build/cjs/identity/types.js +11 -0
  10. package/build/cjs/index.js +11 -1
  11. package/build/cjs/routes/generated.js +72 -0
  12. package/build/cjs/routes/index.js +6 -0
  13. package/build/cjs/routes/overlay.js +38 -0
  14. package/build/esm/corpus/drive.d.ts +14 -0
  15. package/build/esm/corpus/drive.d.ts.map +1 -1
  16. package/build/esm/corpus/identity.d.ts +17 -0
  17. package/build/esm/corpus/identity.d.ts.map +1 -0
  18. package/build/esm/corpus/identity.js +265 -0
  19. package/build/esm/corpus/runner.d.ts +6 -1
  20. package/build/esm/corpus/runner.d.ts.map +1 -1
  21. package/build/esm/corpus/runner.js +11 -2
  22. package/build/esm/host/dispatch.d.ts +16 -0
  23. package/build/esm/host/dispatch.d.ts.map +1 -1
  24. package/build/esm/host/dispatch.js +57 -2
  25. package/build/esm/identity/fixture.d.ts +84 -0
  26. package/build/esm/identity/fixture.d.ts.map +1 -0
  27. package/build/esm/identity/fixture.js +114 -0
  28. package/build/esm/identity/memory-store.d.ts +51 -0
  29. package/build/esm/identity/memory-store.d.ts.map +1 -0
  30. package/build/esm/identity/memory-store.js +175 -0
  31. package/build/esm/identity/service.d.ts +48 -0
  32. package/build/esm/identity/service.d.ts.map +1 -0
  33. package/build/esm/identity/service.js +137 -0
  34. package/build/esm/identity/types.d.ts +79 -0
  35. package/build/esm/identity/types.d.ts.map +1 -0
  36. package/build/esm/identity/types.js +10 -0
  37. package/build/esm/index.d.ts +7 -0
  38. package/build/esm/index.d.ts.map +1 -1
  39. package/build/esm/index.js +4 -0
  40. package/build/esm/routes/generated.d.ts.map +1 -1
  41. package/build/esm/routes/generated.js +72 -0
  42. package/build/esm/routes/index.d.ts.map +1 -1
  43. package/build/esm/routes/index.js +6 -0
  44. package/build/esm/routes/overlay.d.ts +9 -0
  45. package/build/esm/routes/overlay.d.ts.map +1 -1
  46. package/build/esm/routes/overlay.js +38 -0
  47. package/build/esm/types.d.ts +7 -0
  48. package/build/esm/types.d.ts.map +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ /**
2
+ * An `IdentityStore` over maps, for tests and for a host with no database.
3
+ *
4
+ * Tokens are opaque strings this store can look up, which is the shape the
5
+ * browser build needs: no signing key, and nothing in the string a client can
6
+ * read. They are minted from the platform's cryptographic source and carry the
7
+ * client's lifetime, so a guessed string buys nothing and an old one stops
8
+ * resolving. A host with real credentials - a server - supplies its own store
9
+ * with JWTs, bcrypt and a database behind it.
10
+ */
11
+ import type { IdentityClient, IdentityStore, IdentityUser, Subject, TokenPair } from './types.js';
12
+ import { type IdentityFixture } from './fixture.js';
13
+ /**
14
+ * Whether a token minted for one subject names the same user and client.
15
+ *
16
+ * The fixture can declare a token that outlives the map it is seeded into, so
17
+ * the read paths compare rather than trust the reference.
18
+ */
19
+ export declare class MemoryIdentityStore implements IdentityStore {
20
+ private readonly users;
21
+ private readonly passwords;
22
+ private readonly clients;
23
+ private readonly pairs;
24
+ private readonly access;
25
+ private readonly refresh;
26
+ private readonly codes;
27
+ private readonly claimed;
28
+ constructor(fixture?: IdentityFixture);
29
+ userByName(username: string): Promise<IdentityUser | undefined>;
30
+ userById(uid: string): Promise<IdentityUser | undefined>;
31
+ /** A fixture holds the password in clear, so the check is an equality. */
32
+ passwordMatches(user: IdentityUser, password: string): Promise<boolean>;
33
+ client(clientId: string): Promise<IdentityClient | undefined>;
34
+ issue(subject: Subject, client: IdentityClient): Promise<TokenPair>;
35
+ /**
36
+ * A token names its subject only while that subject's CURRENT pair holds it.
37
+ * The server verifies a token against the pair saved for the user and client,
38
+ * so a logged-out or rotated token stops working there too; without the same
39
+ * check here, the browser host would honour a token the server had revoked.
40
+ */
41
+ readAccess(token: string): Promise<Subject | undefined>;
42
+ readRefresh(token: string): Promise<Subject | undefined>;
43
+ private current;
44
+ pairFor(subject: Subject): Promise<TokenPair | undefined>;
45
+ savePair(subject: Subject, pair: TokenPair): Promise<void>;
46
+ deletePair(subject: Subject): Promise<void>;
47
+ issueCode(subject: Subject): Promise<string>;
48
+ readCode(code: string): Promise<Subject | undefined>;
49
+ claimCode(code: string): Promise<boolean>;
50
+ }
51
+ //# sourceMappingURL=memory-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-store.d.ts","sourceRoot":"","sources":["../../../src/identity/memory-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EACV,cAAc,EACd,aAAa,EACb,YAAY,EACZ,OAAO,EACP,SAAS,EACV,MAAM,YAAY,CAAA;AACnB,OAAO,EAGL,KAAK,eAAe,EACrB,MAAM,cAAc,CAAA;AAyDrB;;;;;GAKG;AACH,qBAAa,mBAAoB,YAAW,aAAa;IACvD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4B;IACtD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoC;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAoB;gBAEhC,OAAO,GAAE,eAAkC;IAsDjD,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;IAI/D,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC;IAI9D,0EAA0E;IACpE,eAAe,CACnB,IAAI,EAAE,YAAY,EAClB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,OAAO,CAAC;IAIb,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IAI7D,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,SAAS,CAAC;IAoBzE;;;;;OAKG;IACG,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAIvD,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAI9D,OAAO,CAAC,OAAO;IAeT,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAIzD,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAI1D,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAI3C,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC;IAS5C,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAIpD,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAKhD"}
@@ -0,0 +1,175 @@
1
+ import { DEFAULT_IDENTITY, IDENTITY_FORMAT } from './fixture.js';
2
+ import { ApiError } from '../types.js';
3
+ const keyOf = (subject) => `${subject.userId}|${subject.clientId}`;
4
+ /**
5
+ * Opaque identity strings, from the platform's cryptographic source.
6
+ *
7
+ * These strings are credentials: whoever holds one is the caller it names. A
8
+ * counter or `Math.random` is a sequence a reader can reproduce, so it would
9
+ * let one caller name another - the store is small and demo-shaped, but a token
10
+ * that can be guessed is not a demo quirk, it is an authentication bypass.
11
+ *
12
+ * `globalThis.crypto` is present in every browser and in Node from 19, which is
13
+ * the same reach the rest of this package has (no node imports, so the browser
14
+ * build bundles it unchanged).
15
+ */
16
+ const randomHex = (bytes) => {
17
+ const source = globalThis.crypto;
18
+ if (!source)
19
+ throw new ApiError(500, 'This host has no Web Crypto (`globalThis.crypto`), so it cannot mint an unguessable identity string.');
20
+ const buffer = new Uint8Array(bytes);
21
+ source.getRandomValues(buffer);
22
+ return Array.from(buffer, (byte) => byte.toString(16).padStart(2, '0')).join('');
23
+ };
24
+ /**
25
+ * How long a code buys tokens, in seconds.
26
+ *
27
+ * The same lifetime `sasjs/server` signs into its auth code JWT, so a code a
28
+ * mock issues and one a server issues age out together.
29
+ */
30
+ const CODE_LIFETIME_SECONDS = 30;
31
+ const live = (issued, now) => issued && (issued.expiresAt === undefined || issued.expiresAt > now)
32
+ ? issued.subject
33
+ : undefined;
34
+ /**
35
+ * Whether a token minted for one subject names the same user and client.
36
+ *
37
+ * The fixture can declare a token that outlives the map it is seeded into, so
38
+ * the read paths compare rather than trust the reference.
39
+ */
40
+ export class MemoryIdentityStore {
41
+ constructor(fixture = DEFAULT_IDENTITY) {
42
+ this.users = [];
43
+ this.passwords = new Map();
44
+ this.clients = new Map();
45
+ this.pairs = new Map();
46
+ this.access = new Map();
47
+ this.refresh = new Map();
48
+ this.codes = new Map();
49
+ this.claimed = new Set();
50
+ if (fixture.format !== IDENTITY_FORMAT)
51
+ throw new ApiError(500, `The identity fixture states format ${String(fixture.format)}; this store reads ${IDENTITY_FORMAT}.`);
52
+ for (const user of fixture.users) {
53
+ this.users.push({
54
+ uid: user.uid,
55
+ username: user.username,
56
+ displayName: user.displayName,
57
+ isAdmin: user.isAdmin,
58
+ isActive: user.isActive,
59
+ needsToUpdatePassword: user.needsToUpdatePassword
60
+ });
61
+ this.passwords.set(user.uid, user.password);
62
+ }
63
+ for (const client of fixture.clients)
64
+ this.clients.set(client.clientId, {
65
+ clientId: client.clientId,
66
+ accessTokenExpiration: client.accessTokenExpiration,
67
+ refreshTokenExpiration: client.refreshTokenExpiration
68
+ });
69
+ for (const token of fixture.tokens || []) {
70
+ const user = this.users.find((candidate) => candidate.username === token.username);
71
+ if (!user)
72
+ continue;
73
+ const subject = { userId: user.uid, clientId: token.clientId };
74
+ if (token.accessToken)
75
+ this.access.set(token.accessToken, { subject });
76
+ if (token.refreshToken)
77
+ this.refresh.set(token.refreshToken, { subject });
78
+ // a seeded token is the subject's live pair, which is what makes it
79
+ // authenticate: the read paths check the pair, not the token alone
80
+ if (token.accessToken && token.refreshToken)
81
+ this.pairs.set(keyOf(subject), {
82
+ accessToken: token.accessToken,
83
+ refreshToken: token.refreshToken
84
+ });
85
+ }
86
+ for (const code of fixture.codes || []) {
87
+ const user = this.users.find((candidate) => candidate.username === code.username);
88
+ if (!user)
89
+ continue;
90
+ this.codes.set(code.code, {
91
+ subject: { userId: user.uid, clientId: code.clientId }
92
+ });
93
+ }
94
+ }
95
+ async userByName(username) {
96
+ return this.users.find((user) => user.username === username);
97
+ }
98
+ async userById(uid) {
99
+ return this.users.find((user) => user.uid === uid);
100
+ }
101
+ /** A fixture holds the password in clear, so the check is an equality. */
102
+ async passwordMatches(user, password) {
103
+ return this.passwords.get(user.uid) === password;
104
+ }
105
+ async client(clientId) {
106
+ return this.clients.get(clientId);
107
+ }
108
+ async issue(subject, client) {
109
+ // The lifetimes are the client's, recorded against the instant of issue: a
110
+ // token's expiry is a fact about this pair, so the read paths can answer
111
+ // "invalid or expired" rather than carrying a number nothing compares.
112
+ const issuedAt = Date.now();
113
+ const pair = {
114
+ accessToken: `access-${randomHex(16)}`,
115
+ refreshToken: `refresh-${randomHex(16)}`
116
+ };
117
+ this.access.set(pair.accessToken, {
118
+ subject,
119
+ expiresAt: issuedAt + client.accessTokenExpiration * 1000
120
+ });
121
+ this.refresh.set(pair.refreshToken, {
122
+ subject,
123
+ expiresAt: issuedAt + client.refreshTokenExpiration * 1000
124
+ });
125
+ return pair;
126
+ }
127
+ /**
128
+ * A token names its subject only while that subject's CURRENT pair holds it.
129
+ * The server verifies a token against the pair saved for the user and client,
130
+ * so a logged-out or rotated token stops working there too; without the same
131
+ * check here, the browser host would honour a token the server had revoked.
132
+ */
133
+ async readAccess(token) {
134
+ return this.current(token, this.access, 'accessToken');
135
+ }
136
+ async readRefresh(token) {
137
+ return this.current(token, this.refresh, 'refreshToken');
138
+ }
139
+ current(token, issued, kind) {
140
+ const entry = issued.get(token);
141
+ if (!entry)
142
+ return undefined;
143
+ const subject = live(entry, Date.now());
144
+ if (!subject)
145
+ return undefined;
146
+ const pair = this.pairs.get(keyOf(subject));
147
+ return pair && pair[kind] === token ? subject : undefined;
148
+ }
149
+ async pairFor(subject) {
150
+ return this.pairs.get(keyOf(subject));
151
+ }
152
+ async savePair(subject, pair) {
153
+ this.pairs.set(keyOf(subject), pair);
154
+ }
155
+ async deletePair(subject) {
156
+ this.pairs.delete(keyOf(subject));
157
+ }
158
+ async issueCode(subject) {
159
+ const code = `code-${randomHex(16)}`;
160
+ this.codes.set(code, {
161
+ subject,
162
+ expiresAt: Date.now() + CODE_LIFETIME_SECONDS * 1000
163
+ });
164
+ return code;
165
+ }
166
+ async readCode(code) {
167
+ return live(this.codes.get(code), Date.now());
168
+ }
169
+ async claimCode(code) {
170
+ if (this.claimed.has(code) || !this.codes.has(code))
171
+ return false;
172
+ this.claimed.add(code);
173
+ return true;
174
+ }
175
+ }
@@ -0,0 +1,48 @@
1
+ import type { IdentityStore, IdentityUser, Subject, TokenPair } from './types.js';
2
+ export interface LoginResult {
3
+ loggedIn: boolean;
4
+ user: {
5
+ uid: string;
6
+ username: string;
7
+ displayName: string;
8
+ isAdmin: boolean;
9
+ needsToUpdatePassword: boolean;
10
+ };
11
+ }
12
+ export interface SessionResult {
13
+ uid: string;
14
+ username: string;
15
+ displayName: string;
16
+ isAdmin: boolean;
17
+ needsToUpdatePassword: boolean;
18
+ }
19
+ export interface IdentityService {
20
+ /**
21
+ * The subject an access token names, or undefined. A host that already knows
22
+ * its caller - a session cookie - does not need this.
23
+ */
24
+ authenticate(token: string | undefined): Promise<Subject | undefined>;
25
+ login(credentials: {
26
+ username: string;
27
+ password: string;
28
+ }): Promise<LoginResult>;
29
+ /** The authorization code a signed-in caller exchanges for tokens. */
30
+ authorize(subject: Subject | undefined, clientId: string): Promise<string>;
31
+ /** The token pair an authorization code buys. */
32
+ token(request: {
33
+ clientId: string;
34
+ code: string;
35
+ }): Promise<TokenPair>;
36
+ /** A new pair for the caller a refresh token names. */
37
+ refresh(token: string | undefined): Promise<TokenPair>;
38
+ /** Revokes the caller's pair. Answers nothing; the route is a 204. */
39
+ logout(subject: Subject | undefined): Promise<void>;
40
+ /** The body the browser sign-out answers once the host has reset its session. */
41
+ logoutSession(): Promise<string>;
42
+ /** The caller, as `GET /SASjsApi/session` reports it. */
43
+ session(subject: Subject | undefined): Promise<SessionResult>;
44
+ }
45
+ export declare const createIdentityService: (store: IdentityStore) => IdentityService;
46
+ /** A subject, as the session route reports it. */
47
+ export declare const asSession: (user: IdentityUser) => SessionResult;
48
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/identity/service.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,OAAO,EACP,SAAS,EACV,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,OAAO,CAAA;IACjB,IAAI,EAAE;QACJ,GAAG,EAAE,MAAM,CAAA;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,WAAW,EAAE,MAAM,CAAA;QACnB,OAAO,EAAE,OAAO,CAAA;QAChB,qBAAqB,EAAE,OAAO,CAAA;KAC/B,CAAA;CACF;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,qBAAqB,EAAE,OAAO,CAAA;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;IACrE,KAAK,CAAC,WAAW,EAAE;QACjB,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,MAAM,CAAA;KACjB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;IACxB,sEAAsE;IACtE,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAC1E,iDAAiD;IACjD,KAAK,CAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IACtE,uDAAuD;IACvD,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IACtD,sEAAsE;IACtE,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnD,iFAAiF;IACjF,aAAa,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;IAChC,yDAAyD;IACzD,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;CAC9D;AAsBD,eAAO,MAAM,qBAAqB,GAChC,OAAO,aAAa,KACnB,eAiHD,CAAA;AAEF,kDAAkD;AAClD,eAAO,MAAM,SAAS,GAAI,MAAM,YAAY,KAAG,aAM7C,CAAA"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The sign-in flow's decisions: what each route answers, in which order, with
3
+ * which wording.
4
+ *
5
+ * The transport stays with the host - a session cookie and a CSRF token on a
6
+ * server, whatever the browser build holds - so nothing here reads a request.
7
+ * A host resolves the caller (a bearer token through `authenticate`, a session
8
+ * it already has) and passes the subject in; the service decides whether that
9
+ * is enough for the operation to proceed.
10
+ */
11
+ import { ApiError } from '../types.js';
12
+ const unauthorized = () => new ApiError(401, 'Unauthorized');
13
+ /**
14
+ * The subject, provided the account behind it may still hold a token.
15
+ *
16
+ * `authenticate` reads a token on every request and refuses a deactivated
17
+ * account; an exchange or a refresh that skipped the same check would mint a
18
+ * fresh pair for exactly that account, so the check sits beside every path that
19
+ * reaches `store.issue`.
20
+ */
21
+ const liveSubject = async (store, subject) => {
22
+ const user = await store.userById(subject.userId);
23
+ if (!user || !user.isActive)
24
+ throw unauthorized();
25
+ return subject;
26
+ };
27
+ export const createIdentityService = (store) => ({
28
+ async authenticate(token) {
29
+ if (token === undefined)
30
+ return undefined;
31
+ const subject = await store.readAccess(token);
32
+ if (!subject)
33
+ return undefined;
34
+ // A token names a user who may since have been deactivated. sasjs/server
35
+ // checks `isActive` on every request, so a token that outlived the account
36
+ // must stop resolving here too - otherwise the browser host honours a
37
+ // caller the server would refuse.
38
+ const user = await store.userById(subject.userId);
39
+ return user && user.isActive ? subject : undefined;
40
+ },
41
+ async login({ username, password }) {
42
+ const user = await store.userByName(username);
43
+ // An unknown username and a wrong password answer with different messages
44
+ // on purpose: the server's login form tells the caller which one it was,
45
+ // and the account lockout - the defence against guessing - is the host's.
46
+ if (!user)
47
+ throw new ApiError(401, 'Username is not found.');
48
+ if (!(await store.passwordMatches(user, password)))
49
+ throw new ApiError(401, 'Invalid Password.');
50
+ // Checked after the password, so the answer is not a way to learn that an
51
+ // account exists but is switched off without knowing its password.
52
+ if (!user.isActive)
53
+ throw new ApiError(401, 'This account is not active.');
54
+ return {
55
+ loggedIn: true,
56
+ user: {
57
+ uid: user.uid,
58
+ username: user.username,
59
+ displayName: user.displayName,
60
+ isAdmin: user.isAdmin,
61
+ needsToUpdatePassword: user.needsToUpdatePassword
62
+ }
63
+ };
64
+ },
65
+ async authorize(subject, clientId) {
66
+ // The same answer the server's middleware gives a caller with no token: the
67
+ // two hosts must not describe the same refusal differently.
68
+ if (!subject)
69
+ throw unauthorized();
70
+ if (!(await store.client(clientId)))
71
+ throw new ApiError(403, 'Invalid clientId.');
72
+ return store.issueCode({ userId: subject.userId, clientId });
73
+ },
74
+ async token({ clientId, code }) {
75
+ // Read before claiming: a code presented with another client is refused
76
+ // with the same answer as one that does not verify, and it is not spent, so
77
+ // the caller can still exchange it for the client it was issued for.
78
+ const subject = await store.readCode(code);
79
+ if (!subject || subject.clientId !== clientId)
80
+ throw new ApiError(401, 'Invalid Auth Code');
81
+ // The client is read before the code is spent and before an existing pair is
82
+ // handed back: both the pair and the tokens it would buy belong to the
83
+ // client, so a client deleted since the code was issued refuses the
84
+ // exchange - and refuses it without consuming the code.
85
+ const client = await store.client(clientId);
86
+ if (!client)
87
+ throw new ApiError(401, 'Invalid clientId.');
88
+ const named = await liveSubject(store, subject);
89
+ if (!(await store.claimCode(code)))
90
+ throw new ApiError(401, 'Invalid Auth Code');
91
+ // Re-exchanging a code for the same user and client while a valid pair
92
+ // exists returns that pair: another tab holding the old tokens must not be
93
+ // signed out by a second exchange.
94
+ const existing = await store.pairFor(named);
95
+ if (existing)
96
+ return existing;
97
+ const pair = await store.issue(named, client);
98
+ await store.savePair(named, pair);
99
+ return pair;
100
+ },
101
+ async refresh(token) {
102
+ const subject = token === undefined ? undefined : await store.readRefresh(token);
103
+ if (!subject)
104
+ throw unauthorized();
105
+ const client = await store.client(subject.clientId);
106
+ if (!client)
107
+ throw new ApiError(401, 'Invalid clientId.');
108
+ const named = await liveSubject(store, subject);
109
+ const pair = await store.issue(named, client);
110
+ await store.savePair(named, pair);
111
+ return pair;
112
+ },
113
+ async logout(subject) {
114
+ if (!subject)
115
+ throw unauthorized();
116
+ await store.deletePair(subject);
117
+ },
118
+ async logoutSession() {
119
+ return 'OK!';
120
+ },
121
+ async session(subject) {
122
+ if (!subject)
123
+ throw unauthorized();
124
+ const user = await store.userById(subject.userId);
125
+ if (!user)
126
+ throw unauthorized();
127
+ return asSession(user);
128
+ }
129
+ });
130
+ /** A subject, as the session route reports it. */
131
+ export const asSession = (user) => ({
132
+ uid: user.uid,
133
+ username: user.username,
134
+ displayName: user.displayName,
135
+ isAdmin: user.isAdmin,
136
+ needsToUpdatePassword: user.needsToUpdatePassword
137
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The identity the API works over, as a host supplies it.
3
+ *
4
+ * A server holds users in a database, verifies a bcrypt hash and signs JWTs; a
5
+ * browser build holds a seeded fixture, compares a plain-text demo password and
6
+ * mints opaque strings. Those differences are the host's, so they live behind
7
+ * this port - the statuses, the messages and the order of the checks are the
8
+ * service's, and both hosts run the same corpus over them.
9
+ */
10
+ /** The caller a token names: a user, and the client the token was issued for. */
11
+ export interface Subject {
12
+ userId: string;
13
+ clientId: string;
14
+ }
15
+ export interface IdentityUser {
16
+ uid: string;
17
+ username: string;
18
+ displayName: string;
19
+ isAdmin: boolean;
20
+ isActive: boolean;
21
+ needsToUpdatePassword: boolean;
22
+ }
23
+ export interface IdentityClient {
24
+ clientId: string;
25
+ /** Seconds an access token lives, as the client is configured. */
26
+ accessTokenExpiration: number;
27
+ /** Seconds a refresh token lives. */
28
+ refreshTokenExpiration: number;
29
+ }
30
+ export interface TokenPair {
31
+ accessToken: string;
32
+ refreshToken: string;
33
+ }
34
+ /**
35
+ * Users, clients, token pairs and auth codes, plus the cryptography that names
36
+ * a subject in a token.
37
+ *
38
+ * Minting and reading a token sit here rather than on a separate codec because
39
+ * they are one decision: a host that signs JWTs also verifies them against the
40
+ * same secret, and a host with no signing key issues opaque strings it can look
41
+ * up. Splitting them would let a host pair one host's minting with another's
42
+ * verification, which is never what anyone means.
43
+ */
44
+ export interface IdentityStore {
45
+ userByName(username: string): Promise<IdentityUser | undefined>;
46
+ userById(uid: string): Promise<IdentityUser | undefined>;
47
+ /**
48
+ * Whether the password matches. The host owns the check: a bcrypt hash, a
49
+ * directory bind, or a fixture's declared demo password.
50
+ */
51
+ passwordMatches(user: IdentityUser, password: string): Promise<boolean>;
52
+ client(clientId: string): Promise<IdentityClient | undefined>;
53
+ /** A pair for the subject, with the client's own lifetimes. */
54
+ issue(subject: Subject, client: IdentityClient): Promise<TokenPair>;
55
+ /** The subject an access token names, or undefined when it is invalid or expired. */
56
+ readAccess(token: string): Promise<Subject | undefined>;
57
+ /** The same for a refresh token. A host may keep them in separate stores. */
58
+ readRefresh(token: string): Promise<Subject | undefined>;
59
+ /** The pair currently held for the subject, if the host keeps one. */
60
+ pairFor(subject: Subject): Promise<TokenPair | undefined>;
61
+ savePair(subject: Subject, pair: TokenPair): Promise<void>;
62
+ deletePair(subject: Subject): Promise<void>;
63
+ /** A single-use authorization code naming the subject. */
64
+ issueCode(subject: Subject): Promise<string>;
65
+ /**
66
+ * The subject a code names, without consuming it: authentic and unexpired,
67
+ * or undefined. Read first, so a code presented with the wrong client id is
68
+ * refused without being spent - the caller may still exchange it for the
69
+ * client it was issued for.
70
+ */
71
+ readCode(code: string): Promise<Subject | undefined>;
72
+ /**
73
+ * Spends a code, answering whether this call was the one that did. The first
74
+ * call answers true; every later call for the same string answers false,
75
+ * which is what makes an exchange single-use even when two requests race.
76
+ */
77
+ claimCode(code: string): Promise<boolean>;
78
+ }
79
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/identity/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,iFAAiF;AACjF,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,OAAO,CAAA;IACjB,qBAAqB,EAAE,OAAO,CAAA;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAA;IAChB,kEAAkE;IAClE,qBAAqB,EAAE,MAAM,CAAA;IAC7B,qCAAqC;IACrC,sBAAsB,EAAE,MAAM,CAAA;CAC/B;AAED,MAAM,WAAW,SAAS;IACxB,WAAW,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAA;IAC/D,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAAA;IACxD;;;OAGG;IACH,eAAe,CAAC,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACvE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAA;IAC7D,+DAA+D;IAC/D,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;IACnE,qFAAqF;IACrF,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;IACvD,6EAA6E;IAC7E,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;IACxD,sEAAsE;IACtE,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAA;IACzD,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC1D,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3C,0DAA0D;IAC1D,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAC5C;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;IACpD;;;;OAIG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAC1C"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The identity the API works over, as a host supplies it.
3
+ *
4
+ * A server holds users in a database, verifies a bcrypt hash and signs JWTs; a
5
+ * browser build holds a seeded fixture, compares a plain-text demo password and
6
+ * mints opaque strings. Those differences are the host's, so they live behind
7
+ * this port - the statuses, the messages and the order of the checks are the
8
+ * service's, and both hosts run the same corpus over them.
9
+ */
10
+ export {};
@@ -24,7 +24,14 @@ export { OVERLAY, TAG_CAPABILITIES } from './routes/overlay.js';
24
24
  export type { RouteOverlay } from './routes/overlay.js';
25
25
  export { dispatch, HANDLED_ROUTES } from './host/dispatch.js';
26
26
  export type { ApiRequest, ApiResponse, Services } from './host/dispatch.js';
27
+ export { createIdentityService } from './identity/service.js';
28
+ export type { IdentityService, LoginResult, SessionResult } from './identity/service.js';
29
+ export { MemoryIdentityStore } from './identity/memory-store.js';
30
+ export { DEFAULT_IDENTITY, IDENTITY_FORMAT, parseIdentity } from './identity/fixture.js';
31
+ export type { IdentityFixture, IdentityFixtureClient, IdentityFixtureCode, IdentityFixtureGroup, IdentityFixturePermission, IdentityFixtureToken, IdentityFixtureUser } from './identity/fixture.js';
32
+ export type { IdentityClient, IdentityStore, IdentityUser, Subject, TokenPair } from './identity/types.js';
27
33
  export { DRIVE_CORPUS } from './corpus/drive.js';
34
+ export { IDENTITY_CORPUS } from './corpus/identity.js';
28
35
  export type { CorpusCase } from './corpus/drive.js';
29
36
  export { runCase, servicesFor } from './corpus/runner.js';
30
37
  export type { CaseResult } from './corpus/runner.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,MAAM,EACP,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,YAAY,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,EACT,SAAS,EACV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAChE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAC7D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,cAAc,YAAY,CAAA;AAC1B,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,MAAM,EACP,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,YAAY,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,SAAS,EACT,SAAS,EACV,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAChE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAC/D,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAC7D,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAC7D,YAAY,EACV,eAAe,EACf,WAAW,EACX,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAChE,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACpB,MAAM,uBAAuB,CAAA;AAC9B,YAAY,EACV,cAAc,EACd,aAAa,EACb,YAAY,EACZ,OAAO,EACP,SAAS,EACV,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA"}
@@ -20,5 +20,9 @@ export { MemoryDriveStore } from './drive/memory-store.js';
20
20
  export { ROUTES, routeById, findRoute } from './routes/index.js';
21
21
  export { OVERLAY, TAG_CAPABILITIES } from './routes/overlay.js';
22
22
  export { dispatch, HANDLED_ROUTES } from './host/dispatch.js';
23
+ export { createIdentityService } from './identity/service.js';
24
+ export { MemoryIdentityStore } from './identity/memory-store.js';
25
+ export { DEFAULT_IDENTITY, IDENTITY_FORMAT, parseIdentity } from './identity/fixture.js';
23
26
  export { DRIVE_CORPUS } from './corpus/drive.js';
27
+ export { IDENTITY_CORPUS } from './corpus/identity.js';
24
28
  export { runCase, servicesFor } from './corpus/runner.js';
@@ -1 +1 @@
1
- {"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../../src/routes/generated.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,gBAAgB,EAAE,cAAc,EA+jD5C,CAAA"}
1
+ {"version":3,"file":"generated.d.ts","sourceRoot":"","sources":["../../../src/routes/generated.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAEjD,eAAO,MAAM,gBAAgB,EAAE,cAAc,EA6oD5C,CAAA"}