@ultimabase/auth 0.1.2-canary.20261030145550 → 0.2.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/CHANGELOG.md +22 -3
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.d.ts.map +1 -1
- package/dist/accounts.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin.d.ts +23 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +59 -17
- package/dist/sessions.d.ts +14 -5
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +21 -14
- package/dist/token.d.ts +1 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +1 -1
- package/package.json +8 -8
- package/src/accounts.ts +8 -0
- package/src/index.ts +7 -1
- package/src/plugin.ts +93 -18
- package/src/sessions.ts +36 -13
- package/src/token.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
# @ultimabase/auth
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- fcca799: `User.closeAccount({ id }, { as })` closes an account and keeps its row: it ends the user's sessions, unlinks their accounts, and runs the listeners sign-in methods register with `AuthApi.onCloseAccount`. password's deletes the hash, the tokens and the email requests, and sets `emailVerified` to `false`, so the email signs up again.
|
|
8
|
+
- 99cdfec: `password()` adds a unique `email` field to the user model, which the user and Master read and only Master writes; the password account follows it. `requestEmailChange` and `confirmEmailChange` change the email from a link sent to the new address, and `sendEmail` tells the old one with an `'emailChanged'` email. `AuthApi.unlinkAccount` unlinks one method's account.
|
|
9
|
+
|
|
10
|
+
Breaking: `Email` is a union, so read `token` after checking `kind`; a user model that declares its own `email` stops startup; users stored before this release read `email: null` until a Master `User.update({ where: { id }, data: { email } })` writes their account's email.
|
|
11
|
+
|
|
12
|
+
- fd1263e: A plugin's context gives `dependents`, the plugins that require it, and auth warns at start when no sign-in method requires it (D-164).
|
|
13
|
+
- 6d4f980: `authPlugin({ sessions: { lifetime, longest } })` sets how long sessions last, in milliseconds: 30 days after the last use and 365 days after the sign-in by default (D-165). A session is renewed with less than half its lifetime left. The session cookie's `Max-Age` is now the longest, 365 days by default instead of 400.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [2c3bf4e]
|
|
18
|
+
- Updated dependencies [fd1263e]
|
|
19
|
+
- ultimabase@0.2.0
|
|
20
|
+
- @ultimabase/http@0.2.0
|
|
21
|
+
|
|
22
|
+
## 0.1.2
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
|
6
25
|
|
|
7
|
-
- @ultimabase/http@0.1.2
|
|
8
|
-
- ultimabase@0.1.2
|
|
26
|
+
- @ultimabase/http@0.1.2
|
|
27
|
+
- ultimabase@0.1.2
|
|
9
28
|
|
|
10
29
|
## 0.1.1
|
|
11
30
|
|
package/dist/accounts.d.ts
CHANGED
|
@@ -16,5 +16,7 @@ export declare function accountOf(method: string, subject: string): Promise<{
|
|
|
16
16
|
export declare function subjectOf(userId: string, method: string): Promise<string | undefined>;
|
|
17
17
|
/** Unlinks every account of a user. */
|
|
18
18
|
export declare function unlinkAccounts(userId: string): Promise<void>;
|
|
19
|
+
/** Unlinks `userId`'s account of `method`; none linked is no error (D-162). */
|
|
20
|
+
export declare function unlinkAccount(userId: string, method: string): Promise<void>;
|
|
19
21
|
export {};
|
|
20
22
|
//# sourceMappingURL=accounts.d.ts.map
|
package/dist/accounts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"accounts.d.ts","sourceRoot":"","sources":["../src/accounts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAK/D,KAAK,aAAa,GAAG;IACpB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;CACtD,CAAA;AAED,gCAAgC;AAChC,eAAO,MAAM,WAAW,EAAE,OAAO,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,CAY/D,CAAA;AAEH,qFAAqF;AACrF,wBAAsB,WAAW,CAChC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,0DAA0D;AAC1D,wBAAsB,SAAS,CAC9B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAMlD;AAED,uEAAuE;AACvE,wBAAsB,SAAS,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAM7B;AAED,uCAAuC;AACvC,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAElE"}
|
|
1
|
+
{"version":3,"file":"accounts.d.ts","sourceRoot":"","sources":["../src/accounts.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAK/D,KAAK,aAAa,GAAG;IACpB,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;CACtD,CAAA;AAED,gCAAgC;AAChC,eAAO,MAAM,WAAW,EAAE,OAAO,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,CAY/D,CAAA;AAEH,qFAAqF;AACrF,wBAAsB,WAAW,CAChC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAEf;AAED,0DAA0D;AAC1D,wBAAsB,SAAS,CAC9B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAC,CAMlD;AAED,uEAAuE;AACvE,wBAAsB,SAAS,CAC9B,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAM7B;AAED,uCAAuC;AACvC,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAElE;AAED,+EAA+E;AAC/E,wBAAsB,aAAa,CAClC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACZ,OAAO,CAAC,IAAI,CAAC,CAEf"}
|
package/dist/accounts.js
CHANGED
|
@@ -31,3 +31,7 @@ export async function subjectOf(userId, method) {
|
|
|
31
31
|
export async function unlinkAccounts(userId) {
|
|
32
32
|
await AuthAccount.deleteMany({ where: { userId } }, M);
|
|
33
33
|
}
|
|
34
|
+
/** Unlinks `userId`'s account of `method`; none linked is no error (D-162). */
|
|
35
|
+
export async function unlinkAccount(userId, method) {
|
|
36
|
+
await AuthAccount.deleteMany({ where: { userId, method } }, M);
|
|
37
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { defineUser } from './define-user.ts';
|
|
2
|
-
export type { Auth, AuthApi, AuthCommands, Opened } from './plugin.ts';
|
|
2
|
+
export type { Auth, AuthApi, AuthCommands, AuthOptions, Opened, } from './plugin.ts';
|
|
3
3
|
export { authPlugin } from './plugin.ts';
|
|
4
4
|
export { currentUser, isAuthenticated, isOwner, isSelf, signedIn, } from './rules.ts';
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,YAAY,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC7C,YAAY,EACX,IAAI,EACJ,OAAO,EACP,YAAY,EACZ,WAAW,EACX,MAAM,GACN,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EACN,WAAW,EACX,eAAe,EACf,OAAO,EACP,MAAM,EACN,QAAQ,GACR,MAAM,YAAY,CAAA"}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Http } from '@ultimabase/http';
|
|
2
2
|
import type { Client, Identity, OutputSpec, PluginOf, PluginTypes, UseOf } from 'ultimabase';
|
|
3
|
-
import type { Opened } from './sessions.ts';
|
|
3
|
+
import type { Durations, Opened } from './sessions.ts';
|
|
4
4
|
export type { Opened } from './sessions.ts';
|
|
5
5
|
/** What auth offers the sign-in methods (D-33, D-112, D-113). */
|
|
6
6
|
export interface AuthApi {
|
|
@@ -19,6 +19,18 @@ export interface AuthApi {
|
|
|
19
19
|
} | undefined>;
|
|
20
20
|
/** The subject `userId` is known by to `method`, if linked (D-117). */
|
|
21
21
|
subjectOf(userId: string, method: string): Promise<string | undefined>;
|
|
22
|
+
/** Unlinks `userId`'s account of `method`; none linked is no error (D-162). */
|
|
23
|
+
unlinkAccount(userId: string, method: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Runs `listener` in the transaction of each `User.closeAccount`, after
|
|
26
|
+
* the user's accounts are unlinked: a method deletes what it keeps.
|
|
27
|
+
*/
|
|
28
|
+
onCloseAccount(listener: (userId: string) => Promise<void>): void;
|
|
29
|
+
}
|
|
30
|
+
/** `authPlugin()`'s options (D-165). */
|
|
31
|
+
export interface AuthOptions {
|
|
32
|
+
/** How long sessions last, in milliseconds: 30 and 365 days by default. */
|
|
33
|
+
readonly sessions?: Partial<Durations>;
|
|
22
34
|
}
|
|
23
35
|
/** auth's commands on the user model (D-113). */
|
|
24
36
|
export interface AuthCommands {
|
|
@@ -28,11 +40,21 @@ export interface AuthCommands {
|
|
|
28
40
|
}, options?: {
|
|
29
41
|
readonly as?: Identity;
|
|
30
42
|
}): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Ends every session of the user and unlinks every account, keeping the
|
|
45
|
+
* row, as Master or as the user (D-160).
|
|
46
|
+
*/
|
|
47
|
+
closeAccount(where: {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
}, options?: {
|
|
50
|
+
readonly as?: Identity;
|
|
51
|
+
}): Promise<void>;
|
|
31
52
|
}
|
|
32
53
|
/** auth's types (D-57). */
|
|
33
54
|
export interface Auth extends PluginTypes {
|
|
34
55
|
readonly name: 'auth';
|
|
35
56
|
readonly requires: Http;
|
|
57
|
+
readonly appOptions: AuthOptions;
|
|
36
58
|
readonly api: AuthApi;
|
|
37
59
|
readonly commands: AuthCommands;
|
|
38
60
|
}
|
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EACX,MAAM,EACN,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,WAAW,EAEX,KAAK,EAEL,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAA;AAE5C,OAAO,KAAK,EACX,MAAM,EACN,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,WAAW,EAEX,KAAK,EAEL,MAAM,YAAY,CAAA;AAsBnB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAYtD,YAAY,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAE3C,iEAAiE;AACjE,MAAM,WAAW,OAAO;IACvB;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACxD,oCAAoC;IACpC,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC7C,qFAAqF;IACrF,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3E,0DAA0D;IAC1D,SAAS,CACR,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACb,OAAO,CAAC;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC,CAAA;IACnD,uEAAuE;IACvE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;IACtE,+EAA+E;IAC/E,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5D;;;OAGG;IACH,cAAc,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CACjE;AAED,wCAAwC;AACxC,MAAM,WAAW,WAAW;IAC3B,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,SAAS,CAAC,CAAA;CACtC;AAED,iDAAiD;AACjD,MAAM,WAAW,YAAY;IAC5B,gEAAgE;IAChE,cAAc,CACb,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC9B,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAA;KAAE,GAClC,OAAO,CAAC,IAAI,CAAC,CAAA;IAChB;;;OAGG;IACH,YAAY,CACX,KAAK,EAAE;QAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;KAAE,EAC9B,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAA;KAAE,GAClC,OAAO,CAAC,IAAI,CAAC,CAAA;CAChB;AAED,2BAA2B;AAC3B,MAAM,WAAW,IAAK,SAAQ,WAAW;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;IACvB,QAAQ,CAAC,UAAU,EAAE,WAAW,CAAA;IAChC,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAA;CAC/B;AAED,gFAAgF;AAChF,eAAO,MAAM,UAAU,EAAE,OAAO,CAAC,MAAM,EAAE,UAAU,CAAiB,CAAA;AA8LpE,2EAA2E;AAC3E,eAAO,MAAM,UAAU,EAAE,QAAQ,CAAC,IAAI,CAAe,CAAA;AACrD,uEAAuE;AACvE,eAAO,MAAM,OAAO,EAAE,KAAK,CAAC,IAAI,CAAY,CAAA"}
|
package/dist/plugin.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { httpPlugin } from '@ultimabase/http';
|
|
2
|
-
import { definePlugin, isLocalHost, isMaster, master, PublicError, Refused, } from 'ultimabase';
|
|
2
|
+
import { definePlugin, isLocalHost, isMaster, master, PublicError, Refused, transaction, } from 'ultimabase';
|
|
3
|
+
import { runningApi } from 'ultimabase/internal';
|
|
3
4
|
import manifest from '../package.json' with { type: 'json' };
|
|
4
|
-
import { accountOf, AuthAccount, linkAccount, subjectOf, unlinkAccounts, } from './accounts.js';
|
|
5
|
+
import { accountOf, AuthAccount, linkAccount, subjectOf, unlinkAccount, unlinkAccounts, } from './accounts.js';
|
|
5
6
|
import { authFunctions, callerTokens } from './functions.js';
|
|
6
|
-
import { AuthSession, closeSession, openSession, revokeSessions, sessionOf, } from './sessions.js';
|
|
7
|
+
import { AuthSession, closeSession, durationsOf, openSession, revokeSessions, sessionOf, } from './sessions.js';
|
|
7
8
|
import { bearerOf, cookieAge, cookieOf, cookieTokenOf } from './token.js';
|
|
8
9
|
/** The models `defineUser` returned, each with its output `User`, or `null`. */
|
|
9
10
|
export const userModels = new WeakMap();
|
|
10
11
|
const states = new WeakMap();
|
|
11
12
|
/** The contexts whose insecure cookie was warned about (D-115). */
|
|
12
13
|
const warned = new WeakSet();
|
|
14
|
+
/** What `onCloseAccount` was given, by auth's API. */
|
|
15
|
+
const closers = new WeakMap();
|
|
13
16
|
const M = { as: master };
|
|
14
17
|
function stateOf(context) {
|
|
15
18
|
const state = states.get(context);
|
|
@@ -17,6 +20,19 @@ function stateOf(context) {
|
|
|
17
20
|
throw new Error('auth has not started.');
|
|
18
21
|
return state;
|
|
19
22
|
}
|
|
23
|
+
/** Refuses a caller that is neither Master nor the user `id`. */
|
|
24
|
+
function selfOrMaster(caller, id, command) {
|
|
25
|
+
const self = caller?.kind === 'user' && caller.user.id === id;
|
|
26
|
+
if (!self && (caller === undefined || !isMaster(caller))) {
|
|
27
|
+
throw new PublicError('ACCESS_DENIED', `User.${command} runs as Master or as the user.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The listeners the running application's methods gave `onCloseAccount`. */
|
|
31
|
+
function closersOf() {
|
|
32
|
+
const api = runningApi('auth');
|
|
33
|
+
const listeners = typeof api === 'object' && api !== null ? closers.get(api) : undefined;
|
|
34
|
+
return listeners ?? [];
|
|
35
|
+
}
|
|
20
36
|
/** The one user model among the application's models (D-62). */
|
|
21
37
|
function usersOf(models) {
|
|
22
38
|
const [first, second] = models.flatMap(({ queries }) => {
|
|
@@ -32,8 +48,8 @@ function usersOf(models) {
|
|
|
32
48
|
return first;
|
|
33
49
|
}
|
|
34
50
|
/** A session token's caller, until it ends; `undefined` for an unknown one. */
|
|
35
|
-
async function callerOf(token, users, expires) {
|
|
36
|
-
const session = await sessionOf(token);
|
|
51
|
+
async function callerOf(token, { users, durations }, expires) {
|
|
52
|
+
const session = await sessionOf(token, durations);
|
|
37
53
|
if (session === undefined)
|
|
38
54
|
return undefined;
|
|
39
55
|
// A transport's queries are untyped; this is the user model's row.
|
|
@@ -61,11 +77,19 @@ const auth = definePlugin({
|
|
|
61
77
|
},
|
|
62
78
|
commands: () => ({
|
|
63
79
|
revokeSessions: async ({ id }, options) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
selfOrMaster(options?.as, id, 'revokeSessions');
|
|
81
|
+
await revokeSessions(id);
|
|
82
|
+
},
|
|
83
|
+
closeAccount: async ({ id }, options) => {
|
|
84
|
+
selfOrMaster(options?.as, id, 'closeAccount');
|
|
85
|
+
await transaction(async () => {
|
|
86
|
+
await revokeSessions(id);
|
|
87
|
+
await unlinkAccounts(id);
|
|
88
|
+
for (const listener of closersOf())
|
|
89
|
+
await listener(id);
|
|
90
|
+
});
|
|
91
|
+
// Again once the accounts are gone: a sign-in that read one before
|
|
92
|
+
// opened its session before this ends it.
|
|
69
93
|
await revokeSessions(id);
|
|
70
94
|
},
|
|
71
95
|
}),
|
|
@@ -74,17 +98,22 @@ const auth = definePlugin({
|
|
|
74
98
|
// Built before any plugin starts, so before the server listens.
|
|
75
99
|
api: (context) => {
|
|
76
100
|
const cookie = cookieOf();
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
const durations = durationsOf(context.options.sessions);
|
|
102
|
+
const users = usersOf(context.models);
|
|
103
|
+
states.set(context, { cookie, users, durations });
|
|
104
|
+
// The cookie goes when its session can live no longer.
|
|
105
|
+
const maxAge = Math.min(Math.ceil(durations.longest / 1000), cookieAge);
|
|
106
|
+
const listeners = [];
|
|
107
|
+
const api = {
|
|
79
108
|
signIn: async (userId, client) => {
|
|
80
109
|
// The cookie it replaces would leave a session nobody can end (D-115).
|
|
81
110
|
const replaced = client?.cookies.get(cookie.name);
|
|
82
111
|
if (replaced !== undefined)
|
|
83
112
|
await closeSession(replaced);
|
|
84
|
-
const opened = await openSession(userId);
|
|
113
|
+
const opened = await openSession(userId, durations);
|
|
85
114
|
client?.cookies.set(cookie.name, opened.token, {
|
|
86
115
|
...cookie.options,
|
|
87
|
-
maxAge
|
|
116
|
+
maxAge,
|
|
88
117
|
});
|
|
89
118
|
return opened;
|
|
90
119
|
},
|
|
@@ -92,10 +121,23 @@ const auth = definePlugin({
|
|
|
92
121
|
linkAccount,
|
|
93
122
|
accountOf,
|
|
94
123
|
subjectOf,
|
|
124
|
+
unlinkAccount,
|
|
125
|
+
onCloseAccount: (listener) => {
|
|
126
|
+
listeners.push(listener);
|
|
127
|
+
},
|
|
95
128
|
};
|
|
129
|
+
closers.set(api, listeners);
|
|
130
|
+
return api;
|
|
131
|
+
},
|
|
132
|
+
// A sign-in method requires auth: without one, nobody signs in (D-164).
|
|
133
|
+
start: ({ dependents, logger }) => {
|
|
134
|
+
if (dependents.length > 0)
|
|
135
|
+
return;
|
|
136
|
+
logger.warn('authPlugin() has no sign-in method: add one, as passwordPlugin().');
|
|
96
137
|
},
|
|
97
138
|
identify: async (request, context) => {
|
|
98
|
-
const
|
|
139
|
+
const state = stateOf(context);
|
|
140
|
+
const { cookie } = state;
|
|
99
141
|
const { hostname } = new URL(request.url);
|
|
100
142
|
if (!cookie.options.secure &&
|
|
101
143
|
!warned.has(context) &&
|
|
@@ -105,7 +147,7 @@ const auth = definePlugin({
|
|
|
105
147
|
}
|
|
106
148
|
const bearer = bearerOf(request);
|
|
107
149
|
if (bearer !== undefined) {
|
|
108
|
-
const caller = await callerOf(bearer,
|
|
150
|
+
const caller = await callerOf(bearer, state, context.expires);
|
|
109
151
|
if (caller !== undefined)
|
|
110
152
|
return caller;
|
|
111
153
|
throw new PublicError('UNAUTHENTICATED', 'The session expired or was revoked: sign in again.');
|
|
@@ -115,7 +157,7 @@ const auth = definePlugin({
|
|
|
115
157
|
const token = cookieTokenOf(request, cookie);
|
|
116
158
|
if (token === undefined)
|
|
117
159
|
return undefined;
|
|
118
|
-
return callerOf(token,
|
|
160
|
+
return callerOf(token, state, context.expires);
|
|
119
161
|
},
|
|
120
162
|
});
|
|
121
163
|
/** Sessions and the caller of each request; it requires `httpPlugin()`. */
|
package/dist/sessions.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
|
|
2
|
+
/** How long sessions last, in milliseconds (D-165). */
|
|
3
|
+
export interface Durations {
|
|
4
|
+
/** After the last use; a session used with less than half left is renewed. */
|
|
5
|
+
readonly lifetime: number;
|
|
6
|
+
/** After the sign-in, which no renewal goes past (D-115). */
|
|
7
|
+
readonly longest: number;
|
|
8
|
+
}
|
|
9
|
+
/** `authPlugin`'s `sessions`, checked, with 30 and 365 days by default. */
|
|
10
|
+
export declare function durationsOf(sessions?: Partial<Durations>): Durations;
|
|
2
11
|
type SessionFields = {
|
|
3
12
|
readonly tokenHash: Field<string, {
|
|
4
13
|
readonly unique: true;
|
|
@@ -14,13 +23,13 @@ export interface Opened {
|
|
|
14
23
|
readonly expiresAt: Date;
|
|
15
24
|
}
|
|
16
25
|
/** Opens a session for `userId`, ending every expired one (D-115). */
|
|
17
|
-
export declare function openSession(userId: string): Promise<Opened>;
|
|
26
|
+
export declare function openSession(userId: string, { lifetime }: Durations): Promise<Opened>;
|
|
18
27
|
/**
|
|
19
|
-
* The session `token` opened, renewed when it has less than
|
|
20
|
-
* to
|
|
21
|
-
* expired one, deleted.
|
|
28
|
+
* The session `token` opened, renewed when it has less than half its lifetime
|
|
29
|
+
* left, up to the longest after its sign-in; `undefined` for an unknown one,
|
|
30
|
+
* or an expired one, deleted.
|
|
22
31
|
*/
|
|
23
|
-
export declare function sessionOf(token: string): Promise<{
|
|
32
|
+
export declare function sessionOf(token: string, { lifetime, longest }: Durations): Promise<{
|
|
24
33
|
readonly userId: string;
|
|
25
34
|
readonly expiresAt: Date;
|
|
26
35
|
} | undefined>;
|
package/dist/sessions.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../src/sessions.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;
|
|
1
|
+
{"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../src/sessions.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAM/D,uDAAuD;AACvD,MAAM,WAAW,SAAS;IACzB,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACxB;AAID,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,QAAQ,GAAE,OAAO,CAAC,SAAS,CAAM,GAAG,SAAS,CAWxE;AAED,KAAK,aAAa,GAAG;IACpB,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IAC5D,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACrD,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC,CAAC,CAAA;CACpD,CAAA;AAED,gCAAgC;AAChC,eAAO,MAAM,WAAW,EAAE,OAAO,CAAC,aAAa,EAAE,aAAa,EAAE,EAAE,CAS/D,CAAA;AAKH,oDAAoD;AACpD,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;CACxB;AAED,sEAAsE;AACtE,wBAAsB,WAAW,CAChC,MAAM,EAAE,MAAM,EACd,EAAE,QAAQ,EAAE,EAAE,SAAS,GACrB,OAAO,CAAC,MAAM,CAAC,CAajB;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC9B,KAAK,EAAE,MAAM,EACb,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,SAAS,GAC9B,OAAO,CAAC;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GAAG,SAAS,CAAC,CA2B5E;AAED,+CAA+C;AAC/C,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/D;AAED,oCAAoC;AACpC,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAElE"}
|
package/dist/sessions.js
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
// Sessions (D-112): an opaque token, of which the database keeps only the
|
|
2
|
-
// SHA-256 digest, ending
|
|
3
|
-
// sign-in (D-115).
|
|
2
|
+
// SHA-256 digest, ending a lifetime after its last use and at the longest
|
|
3
|
+
// after its sign-in (D-115), both the application's (D-165).
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
-
import { defineModel, master, PublicError } from 'ultimabase';
|
|
5
|
+
import { defineModel, master, PublicError, Refused } from 'ultimabase';
|
|
6
6
|
const M = { as: master };
|
|
7
7
|
const day = 24 * 60 * 60 * 1000;
|
|
8
|
-
const
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
const positive = (value) => Number.isFinite(value) && value > 0;
|
|
9
|
+
/** `authPlugin`'s `sessions`, checked, with 30 and 365 days by default. */
|
|
10
|
+
export function durationsOf(sessions = {}) {
|
|
11
|
+
const { lifetime = 30 * day, longest = 365 * day } = sessions;
|
|
12
|
+
if (!positive(lifetime) || !positive(longest)) {
|
|
13
|
+
throw new Refused('authPlugin() sessions: lifetime and longest are positive numbers of milliseconds.');
|
|
14
|
+
}
|
|
15
|
+
if (lifetime > longest) {
|
|
16
|
+
throw new Refused('authPlugin() sessions: lifetime is longer than longest.');
|
|
17
|
+
}
|
|
18
|
+
return { lifetime, longest };
|
|
19
|
+
}
|
|
13
20
|
/** auth's own model (D-110). */
|
|
14
21
|
export const AuthSession = defineModel({
|
|
15
22
|
name: 'AuthSession',
|
|
@@ -22,7 +29,7 @@ export const AuthSession = defineModel({
|
|
|
22
29
|
});
|
|
23
30
|
const digest = (token) => createHash('sha256').update(token).digest('base64url');
|
|
24
31
|
/** Opens a session for `userId`, ending every expired one (D-115). */
|
|
25
|
-
export async function openSession(userId) {
|
|
32
|
+
export async function openSession(userId, { lifetime }) {
|
|
26
33
|
const now = Date.now();
|
|
27
34
|
await AuthSession.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
|
|
28
35
|
const token = randomBytes(32).toString('base64url');
|
|
@@ -31,11 +38,11 @@ export async function openSession(userId) {
|
|
|
31
38
|
return { token, expiresAt };
|
|
32
39
|
}
|
|
33
40
|
/**
|
|
34
|
-
* The session `token` opened, renewed when it has less than
|
|
35
|
-
* to
|
|
36
|
-
* expired one, deleted.
|
|
41
|
+
* The session `token` opened, renewed when it has less than half its lifetime
|
|
42
|
+
* left, up to the longest after its sign-in; `undefined` for an unknown one,
|
|
43
|
+
* or an expired one, deleted.
|
|
37
44
|
*/
|
|
38
|
-
export async function sessionOf(token) {
|
|
45
|
+
export async function sessionOf(token, { lifetime, longest }) {
|
|
39
46
|
const tokenHash = digest(token);
|
|
40
47
|
const session = await AuthSession.findUnique({ where: { tokenHash } }, M);
|
|
41
48
|
if (session === null)
|
|
@@ -47,7 +54,7 @@ export async function sessionOf(token) {
|
|
|
47
54
|
return undefined;
|
|
48
55
|
}
|
|
49
56
|
const end = Math.min(now + lifetime, session.createdAt.getTime() + longest);
|
|
50
|
-
if (left <
|
|
57
|
+
if (left < lifetime / 2 && end > session.expiresAt.getTime()) {
|
|
51
58
|
try {
|
|
52
59
|
await AuthSession.update({ where: { tokenHash }, data: { expiresAt: new Date(end) } }, M);
|
|
53
60
|
}
|
package/dist/token.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CookieOptions } from 'ultimabase';
|
|
2
|
-
/** The longest a browser keeps a cookie: 400 days. */
|
|
2
|
+
/** The longest a browser keeps a cookie: 400 days, in seconds. */
|
|
3
3
|
export declare const cookieAge: number;
|
|
4
4
|
/** The cookie's name and attributes, read once when the application starts. */
|
|
5
5
|
export interface Cookie {
|
package/dist/token.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE/C,
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE/C,kEAAkE;AAClE,eAAO,MAAM,SAAS,EAAE,MAA2B,CAAA;AAEnD,+EAA+E;AAC/E,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;CAC/B;AAED,6EAA6E;AAC7E,wBAAgB,QAAQ,IAAI,MAAM,CAOjC;AAED;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAK7D;AAED,0CAA0C;AAC1C,wBAAgB,aAAa,CAC5B,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GACZ,MAAM,GAAG,SAAS,CAOpB"}
|
package/dist/token.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The longest a browser keeps a cookie: 400 days. */
|
|
1
|
+
/** The longest a browser keeps a cookie: 400 days, in seconds. */
|
|
2
2
|
export const cookieAge = 400 * 24 * 60 * 60;
|
|
3
3
|
/** Secure and `__Host-` in production, so it works on `http://localhost`. */
|
|
4
4
|
export function cookieOf() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Authentication for UltimaBase: the user model, sessions and the caller of each request.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -31,18 +31,18 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@ultimabase/http": "0.
|
|
35
|
-
"ultimabase": "0.
|
|
34
|
+
"@ultimabase/http": "^0.2.0",
|
|
35
|
+
"ultimabase": "^0.2.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/pg": "8.23.1",
|
|
39
|
-
"@ultimabase/graphql": "0.
|
|
40
|
-
"@ultimabase/http": "0.
|
|
41
|
-
"@ultimabase/postgres": "0.
|
|
42
|
-
"@ultimabase/testing": "0.
|
|
39
|
+
"@ultimabase/graphql": "0.2.0",
|
|
40
|
+
"@ultimabase/http": "0.2.0",
|
|
41
|
+
"@ultimabase/postgres": "0.2.0",
|
|
42
|
+
"@ultimabase/testing": "0.2.0",
|
|
43
43
|
"graphql": "17.0.2",
|
|
44
44
|
"pg": "8.23.1",
|
|
45
|
-
"ultimabase": "0.
|
|
45
|
+
"ultimabase": "0.2.0",
|
|
46
46
|
"vite": "8.3.4",
|
|
47
47
|
"vitest": "5.0.5"
|
|
48
48
|
}
|
package/src/accounts.ts
CHANGED
|
@@ -63,3 +63,11 @@ export async function subjectOf(
|
|
|
63
63
|
export async function unlinkAccounts(userId: string): Promise<void> {
|
|
64
64
|
await AuthAccount.deleteMany({ where: { userId } }, M)
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/** Unlinks `userId`'s account of `method`; none linked is no error (D-162). */
|
|
68
|
+
export async function unlinkAccount(
|
|
69
|
+
userId: string,
|
|
70
|
+
method: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
await AuthAccount.deleteMany({ where: { userId, method } }, M)
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// `@ultimabase/auth` (D-112, D-113): the user model, sessions, linked
|
|
2
2
|
// accounts, the caller of each request, and the rules that read it.
|
|
3
3
|
export { defineUser } from './define-user.ts'
|
|
4
|
-
export type {
|
|
4
|
+
export type {
|
|
5
|
+
Auth,
|
|
6
|
+
AuthApi,
|
|
7
|
+
AuthCommands,
|
|
8
|
+
AuthOptions,
|
|
9
|
+
Opened,
|
|
10
|
+
} from './plugin.ts'
|
|
5
11
|
export { authPlugin } from './plugin.ts'
|
|
6
12
|
export {
|
|
7
13
|
currentUser,
|
package/src/plugin.ts
CHANGED
|
@@ -20,21 +20,25 @@ import {
|
|
|
20
20
|
master,
|
|
21
21
|
PublicError,
|
|
22
22
|
Refused,
|
|
23
|
+
transaction,
|
|
23
24
|
} from 'ultimabase'
|
|
25
|
+
import { runningApi } from 'ultimabase/internal'
|
|
24
26
|
import manifest from '../package.json' with { type: 'json' }
|
|
25
27
|
import {
|
|
26
28
|
accountOf,
|
|
27
29
|
AuthAccount,
|
|
28
30
|
linkAccount,
|
|
29
31
|
subjectOf,
|
|
32
|
+
unlinkAccount,
|
|
30
33
|
unlinkAccounts,
|
|
31
34
|
} from './accounts.ts'
|
|
32
35
|
import type { Users } from './functions.ts'
|
|
33
36
|
import { authFunctions, callerTokens } from './functions.ts'
|
|
34
|
-
import type { Opened } from './sessions.ts'
|
|
37
|
+
import type { Durations, Opened } from './sessions.ts'
|
|
35
38
|
import {
|
|
36
39
|
AuthSession,
|
|
37
40
|
closeSession,
|
|
41
|
+
durationsOf,
|
|
38
42
|
openSession,
|
|
39
43
|
revokeSessions,
|
|
40
44
|
sessionOf,
|
|
@@ -62,6 +66,19 @@ export interface AuthApi {
|
|
|
62
66
|
): Promise<{ readonly userId: string } | undefined>
|
|
63
67
|
/** The subject `userId` is known by to `method`, if linked (D-117). */
|
|
64
68
|
subjectOf(userId: string, method: string): Promise<string | undefined>
|
|
69
|
+
/** Unlinks `userId`'s account of `method`; none linked is no error (D-162). */
|
|
70
|
+
unlinkAccount(userId: string, method: string): Promise<void>
|
|
71
|
+
/**
|
|
72
|
+
* Runs `listener` in the transaction of each `User.closeAccount`, after
|
|
73
|
+
* the user's accounts are unlinked: a method deletes what it keeps.
|
|
74
|
+
*/
|
|
75
|
+
onCloseAccount(listener: (userId: string) => Promise<void>): void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `authPlugin()`'s options (D-165). */
|
|
79
|
+
export interface AuthOptions {
|
|
80
|
+
/** How long sessions last, in milliseconds: 30 and 365 days by default. */
|
|
81
|
+
readonly sessions?: Partial<Durations>
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
/** auth's commands on the user model (D-113). */
|
|
@@ -71,12 +88,21 @@ export interface AuthCommands {
|
|
|
71
88
|
where: { readonly id: string },
|
|
72
89
|
options?: { readonly as?: Identity },
|
|
73
90
|
): Promise<void>
|
|
91
|
+
/**
|
|
92
|
+
* Ends every session of the user and unlinks every account, keeping the
|
|
93
|
+
* row, as Master or as the user (D-160).
|
|
94
|
+
*/
|
|
95
|
+
closeAccount(
|
|
96
|
+
where: { readonly id: string },
|
|
97
|
+
options?: { readonly as?: Identity },
|
|
98
|
+
): Promise<void>
|
|
74
99
|
}
|
|
75
100
|
|
|
76
101
|
/** auth's types (D-57). */
|
|
77
102
|
export interface Auth extends PluginTypes {
|
|
78
103
|
readonly name: 'auth'
|
|
79
104
|
readonly requires: Http
|
|
105
|
+
readonly appOptions: AuthOptions
|
|
80
106
|
readonly api: AuthApi
|
|
81
107
|
readonly commands: AuthCommands
|
|
82
108
|
}
|
|
@@ -88,10 +114,13 @@ export const userModels: WeakMap<object, OutputSpec> = new WeakMap()
|
|
|
88
114
|
interface State {
|
|
89
115
|
readonly cookie: Cookie
|
|
90
116
|
readonly users: Users
|
|
117
|
+
readonly durations: Durations
|
|
91
118
|
}
|
|
92
119
|
const states = new WeakMap<object, State>()
|
|
93
120
|
/** The contexts whose insecure cookie was warned about (D-115). */
|
|
94
121
|
const warned = new WeakSet<object>()
|
|
122
|
+
/** What `onCloseAccount` was given, by auth's API. */
|
|
123
|
+
const closers = new WeakMap<object, ((userId: string) => Promise<void>)[]>()
|
|
95
124
|
|
|
96
125
|
const M = { as: master }
|
|
97
126
|
|
|
@@ -101,6 +130,29 @@ function stateOf(context: object): State {
|
|
|
101
130
|
return state
|
|
102
131
|
}
|
|
103
132
|
|
|
133
|
+
/** Refuses a caller that is neither Master nor the user `id`. */
|
|
134
|
+
function selfOrMaster(
|
|
135
|
+
caller: Identity | undefined,
|
|
136
|
+
id: string,
|
|
137
|
+
command: string,
|
|
138
|
+
): void {
|
|
139
|
+
const self = caller?.kind === 'user' && caller.user.id === id
|
|
140
|
+
if (!self && (caller === undefined || !isMaster(caller))) {
|
|
141
|
+
throw new PublicError(
|
|
142
|
+
'ACCESS_DENIED',
|
|
143
|
+
`User.${command} runs as Master or as the user.`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The listeners the running application's methods gave `onCloseAccount`. */
|
|
149
|
+
function closersOf(): readonly ((userId: string) => Promise<void>)[] {
|
|
150
|
+
const api = runningApi('auth')
|
|
151
|
+
const listeners =
|
|
152
|
+
typeof api === 'object' && api !== null ? closers.get(api) : undefined
|
|
153
|
+
return listeners ?? []
|
|
154
|
+
}
|
|
155
|
+
|
|
104
156
|
/** The one user model among the application's models (D-62). */
|
|
105
157
|
function usersOf(
|
|
106
158
|
models: readonly { readonly queries: Users['queries'] }[],
|
|
@@ -125,10 +177,10 @@ function usersOf(
|
|
|
125
177
|
/** A session token's caller, until it ends; `undefined` for an unknown one. */
|
|
126
178
|
async function callerOf(
|
|
127
179
|
token: string,
|
|
128
|
-
users:
|
|
180
|
+
{ users, durations }: State,
|
|
129
181
|
expires: (at: Date) => void,
|
|
130
182
|
): Promise<Identity | undefined> {
|
|
131
|
-
const session = await sessionOf(token)
|
|
183
|
+
const session = await sessionOf(token, durations)
|
|
132
184
|
if (session === undefined) return undefined
|
|
133
185
|
// A transport's queries are untyped; this is the user model's row.
|
|
134
186
|
const user: unknown = await users.queries.findUnique(
|
|
@@ -158,14 +210,18 @@ const auth = definePlugin<Auth>({
|
|
|
158
210
|
},
|
|
159
211
|
commands: () => ({
|
|
160
212
|
revokeSessions: async ({ id }, options) => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
|
|
213
|
+
selfOrMaster(options?.as, id, 'revokeSessions')
|
|
214
|
+
await revokeSessions(id)
|
|
215
|
+
},
|
|
216
|
+
closeAccount: async ({ id }, options) => {
|
|
217
|
+
selfOrMaster(options?.as, id, 'closeAccount')
|
|
218
|
+
await transaction(async () => {
|
|
219
|
+
await revokeSessions(id)
|
|
220
|
+
await unlinkAccounts(id)
|
|
221
|
+
for (const listener of closersOf()) await listener(id)
|
|
222
|
+
})
|
|
223
|
+
// Again once the accounts are gone: a sign-in that read one before
|
|
224
|
+
// opened its session before this ends it.
|
|
169
225
|
await revokeSessions(id)
|
|
170
226
|
},
|
|
171
227
|
}),
|
|
@@ -174,16 +230,21 @@ const auth = definePlugin<Auth>({
|
|
|
174
230
|
// Built before any plugin starts, so before the server listens.
|
|
175
231
|
api: (context) => {
|
|
176
232
|
const cookie = cookieOf()
|
|
177
|
-
|
|
178
|
-
|
|
233
|
+
const durations = durationsOf(context.options.sessions)
|
|
234
|
+
const users = usersOf(context.models)
|
|
235
|
+
states.set(context, { cookie, users, durations })
|
|
236
|
+
// The cookie goes when its session can live no longer.
|
|
237
|
+
const maxAge = Math.min(Math.ceil(durations.longest / 1000), cookieAge)
|
|
238
|
+
const listeners: ((userId: string) => Promise<void>)[] = []
|
|
239
|
+
const api: AuthApi = {
|
|
179
240
|
signIn: async (userId, client) => {
|
|
180
241
|
// The cookie it replaces would leave a session nobody can end (D-115).
|
|
181
242
|
const replaced = client?.cookies.get(cookie.name)
|
|
182
243
|
if (replaced !== undefined) await closeSession(replaced)
|
|
183
|
-
const opened = await openSession(userId)
|
|
244
|
+
const opened = await openSession(userId, durations)
|
|
184
245
|
client?.cookies.set(cookie.name, opened.token, {
|
|
185
246
|
...cookie.options,
|
|
186
|
-
maxAge
|
|
247
|
+
maxAge,
|
|
187
248
|
})
|
|
188
249
|
return opened
|
|
189
250
|
},
|
|
@@ -191,10 +252,24 @@ const auth = definePlugin<Auth>({
|
|
|
191
252
|
linkAccount,
|
|
192
253
|
accountOf,
|
|
193
254
|
subjectOf,
|
|
255
|
+
unlinkAccount,
|
|
256
|
+
onCloseAccount: (listener) => {
|
|
257
|
+
listeners.push(listener)
|
|
258
|
+
},
|
|
194
259
|
}
|
|
260
|
+
closers.set(api, listeners)
|
|
261
|
+
return api
|
|
262
|
+
},
|
|
263
|
+
// A sign-in method requires auth: without one, nobody signs in (D-164).
|
|
264
|
+
start: ({ dependents, logger }) => {
|
|
265
|
+
if (dependents.length > 0) return
|
|
266
|
+
logger.warn(
|
|
267
|
+
'authPlugin() has no sign-in method: add one, as passwordPlugin().',
|
|
268
|
+
)
|
|
195
269
|
},
|
|
196
270
|
identify: async (request, context) => {
|
|
197
|
-
const
|
|
271
|
+
const state = stateOf(context)
|
|
272
|
+
const { cookie } = state
|
|
198
273
|
const { hostname } = new URL(request.url)
|
|
199
274
|
if (
|
|
200
275
|
!cookie.options.secure &&
|
|
@@ -208,7 +283,7 @@ const auth = definePlugin<Auth>({
|
|
|
208
283
|
}
|
|
209
284
|
const bearer = bearerOf(request)
|
|
210
285
|
if (bearer !== undefined) {
|
|
211
|
-
const caller = await callerOf(bearer,
|
|
286
|
+
const caller = await callerOf(bearer, state, context.expires)
|
|
212
287
|
if (caller !== undefined) return caller
|
|
213
288
|
throw new PublicError(
|
|
214
289
|
'UNAUTHENTICATED',
|
|
@@ -219,7 +294,7 @@ const auth = definePlugin<Auth>({
|
|
|
219
294
|
// signing in again (D-112).
|
|
220
295
|
const token = cookieTokenOf(request, cookie)
|
|
221
296
|
if (token === undefined) return undefined
|
|
222
|
-
return callerOf(token,
|
|
297
|
+
return callerOf(token, state, context.expires)
|
|
223
298
|
},
|
|
224
299
|
})
|
|
225
300
|
|
package/src/sessions.ts
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
// Sessions (D-112): an opaque token, of which the database keeps only the
|
|
2
|
-
// SHA-256 digest, ending
|
|
3
|
-
// sign-in (D-115).
|
|
2
|
+
// SHA-256 digest, ending a lifetime after its last use and at the longest
|
|
3
|
+
// after its sign-in (D-115), both the application's (D-165).
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
6
|
-
import { defineModel, master, PublicError } from 'ultimabase'
|
|
6
|
+
import { defineModel, master, PublicError, Refused } from 'ultimabase'
|
|
7
7
|
|
|
8
8
|
const M = { as: master }
|
|
9
9
|
const day = 24 * 60 * 60 * 1000
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
10
|
+
|
|
11
|
+
/** How long sessions last, in milliseconds (D-165). */
|
|
12
|
+
export interface Durations {
|
|
13
|
+
/** After the last use; a session used with less than half left is renewed. */
|
|
14
|
+
readonly lifetime: number
|
|
15
|
+
/** After the sign-in, which no renewal goes past (D-115). */
|
|
16
|
+
readonly longest: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const positive = (value: number): boolean => Number.isFinite(value) && value > 0
|
|
20
|
+
|
|
21
|
+
/** `authPlugin`'s `sessions`, checked, with 30 and 365 days by default. */
|
|
22
|
+
export function durationsOf(sessions: Partial<Durations> = {}): Durations {
|
|
23
|
+
const { lifetime = 30 * day, longest = 365 * day } = sessions
|
|
24
|
+
if (!positive(lifetime) || !positive(longest)) {
|
|
25
|
+
throw new Refused(
|
|
26
|
+
'authPlugin() sessions: lifetime and longest are positive numbers of milliseconds.',
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
if (lifetime > longest) {
|
|
30
|
+
throw new Refused('authPlugin() sessions: lifetime is longer than longest.')
|
|
31
|
+
}
|
|
32
|
+
return { lifetime, longest }
|
|
33
|
+
}
|
|
15
34
|
|
|
16
35
|
type SessionFields = {
|
|
17
36
|
readonly tokenHash: Field<string, { readonly unique: true }>
|
|
@@ -41,7 +60,10 @@ export interface Opened {
|
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
/** Opens a session for `userId`, ending every expired one (D-115). */
|
|
44
|
-
export async function openSession(
|
|
63
|
+
export async function openSession(
|
|
64
|
+
userId: string,
|
|
65
|
+
{ lifetime }: Durations,
|
|
66
|
+
): Promise<Opened> {
|
|
45
67
|
const now = Date.now()
|
|
46
68
|
await AuthSession.deleteMany(
|
|
47
69
|
{ where: { expiresAt: { lte: new Date(now) } } },
|
|
@@ -57,12 +79,13 @@ export async function openSession(userId: string): Promise<Opened> {
|
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
/**
|
|
60
|
-
* The session `token` opened, renewed when it has less than
|
|
61
|
-
* to
|
|
62
|
-
* expired one, deleted.
|
|
82
|
+
* The session `token` opened, renewed when it has less than half its lifetime
|
|
83
|
+
* left, up to the longest after its sign-in; `undefined` for an unknown one,
|
|
84
|
+
* or an expired one, deleted.
|
|
63
85
|
*/
|
|
64
86
|
export async function sessionOf(
|
|
65
87
|
token: string,
|
|
88
|
+
{ lifetime, longest }: Durations,
|
|
66
89
|
): Promise<{ readonly userId: string; readonly expiresAt: Date } | undefined> {
|
|
67
90
|
const tokenHash = digest(token)
|
|
68
91
|
const session = await AuthSession.findUnique({ where: { tokenHash } }, M)
|
|
@@ -74,7 +97,7 @@ export async function sessionOf(
|
|
|
74
97
|
return undefined
|
|
75
98
|
}
|
|
76
99
|
const end = Math.min(now + lifetime, session.createdAt.getTime() + longest)
|
|
77
|
-
if (left <
|
|
100
|
+
if (left < lifetime / 2 && end > session.expiresAt.getTime()) {
|
|
78
101
|
try {
|
|
79
102
|
await AuthSession.update(
|
|
80
103
|
{ where: { tokenHash }, data: { expiresAt: new Date(end) } },
|
package/src/token.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the cookie, which auth sets and clears through a function's client (D-113).
|
|
3
3
|
import type { CookieOptions } from 'ultimabase'
|
|
4
4
|
|
|
5
|
-
/** The longest a browser keeps a cookie: 400 days. */
|
|
5
|
+
/** The longest a browser keeps a cookie: 400 days, in seconds. */
|
|
6
6
|
export const cookieAge: number = 400 * 24 * 60 * 60
|
|
7
7
|
|
|
8
8
|
/** The cookie's name and attributes, read once when the application starts. */
|