@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 CHANGED
@@ -1,11 +1,30 @@
1
1
  # @ultimabase/auth
2
2
 
3
- ## 0.1.2-canary.20261030145550
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-canary.20261030145550
8
- - ultimabase@0.1.2-canary.20261030145550
26
+ - @ultimabase/http@0.1.2
27
+ - ultimabase@0.1.2
9
28
 
10
29
  ## 0.1.1
11
30
 
@@ -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
@@ -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
@@ -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,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACtE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EACN,WAAW,EACX,eAAe,EACf,OAAO,EACP,MAAM,EACN,QAAQ,GACR,MAAM,YAAY,CAAA"}
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
  }
@@ -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;AAmBnB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAW3C,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;CACtE;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;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,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;AA6IpE,2EAA2E;AAC3E,eAAO,MAAM,UAAU,EAAE,QAAQ,CAAC,IAAI,CAAe,CAAA;AACrD,uEAAuE;AACvE,eAAO,MAAM,OAAO,EAAE,KAAK,CAAC,IAAI,CAAY,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
- const caller = options?.as;
65
- const self = caller?.kind === 'user' && caller.user.id === id;
66
- if (!self && (caller === undefined || !isMaster(caller))) {
67
- throw new PublicError('ACCESS_DENIED', 'User.revokeSessions runs as Master or as the user.');
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
- states.set(context, { cookie, users: usersOf(context.models) });
78
- return {
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: cookieAge,
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 { cookie, users } = stateOf(context);
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, users, context.expires);
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, users, context.expires);
160
+ return callerOf(token, state, context.expires);
119
161
  },
120
162
  });
121
163
  /** Sessions and the caller of each request; it requires `httpPlugin()`. */
@@ -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 15 days left, up
20
- * to one year after its sign-in; `undefined` for an unknown one, or an
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>;
@@ -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;AAW/D,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,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAajE;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC9B,KAAK,EAAE,MAAM,GACX,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"}
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 30 days after its last use and one year after its
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 lifetime = 30 * day;
9
- /** A session used with less left gets its lifetime again. */
10
- const renewal = 15 * day;
11
- /** No renewal takes a session past this long after its sign-in (D-115). */
12
- const longest = 365 * day;
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 15 days left, up
35
- * to one year after its sign-in; `undefined` for an unknown one, or an
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 < renewal && end > session.expiresAt.getTime()) {
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 {
@@ -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,sDAAsD;AACtD,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"}
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.1.2-canary.20261030145550",
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.1.2-canary.20261030145550",
35
- "ultimabase": "0.1.2-canary.20261030145550"
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.1.2-canary.20261030145550",
40
- "@ultimabase/http": "0.1.2-canary.20261030145550",
41
- "@ultimabase/postgres": "0.1.2-canary.20261030145550",
42
- "@ultimabase/testing": "0.1.2-canary.20261030145550",
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.1.2-canary.20261030145550",
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 { Auth, AuthApi, AuthCommands, Opened } from './plugin.ts'
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: 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
- const caller = options?.as
162
- const self = caller?.kind === 'user' && caller.user.id === id
163
- if (!self && (caller === undefined || !isMaster(caller))) {
164
- throw new PublicError(
165
- 'ACCESS_DENIED',
166
- 'User.revokeSessions runs as Master or as the user.',
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
- states.set(context, { cookie, users: usersOf(context.models) })
178
- return {
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: cookieAge,
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 { cookie, users } = stateOf(context)
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, users, context.expires)
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, users, context.expires)
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 30 days after its last use and one year after its
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
- const lifetime = 30 * day
11
- /** A session used with less left gets its lifetime again. */
12
- const renewal = 15 * day
13
- /** No renewal takes a session past this long after its sign-in (D-115). */
14
- const longest = 365 * day
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(userId: string): Promise<Opened> {
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 15 days left, up
61
- * to one year after its sign-in; `undefined` for an unknown one, or an
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 < renewal && end > session.expiresAt.getTime()) {
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. */