@ultimabase/auth 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,13 @@
1
+ # @ultimabase/auth
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [0e885c4]
12
+ - @ultimabase/http@0.1.0
13
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/auth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Authentication for UltimaBase. It declares the user model, keeps the sessions, and gives each request its caller: the user its session names. A sign-in plugin, as `@ultimabase/password`, adds the way to sign in.
4
+
5
+ Documentation: [Authentication](https://ultimabase.dev/docs/auth).
@@ -0,0 +1,20 @@
1
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
2
+ type AccountFields = {
3
+ readonly userId: Field<string, ScalarOptions<string>>;
4
+ readonly method: Field<string, ScalarOptions<string>>;
5
+ readonly subject: Field<string, ScalarOptions<string>>;
6
+ };
7
+ /** auth's own model (D-110). */
8
+ export declare const AuthAccount: ModelOf<'AuthAccount', AccountFields, []>;
9
+ /** Links `userId` to `subject` of `method`; a taken pair is a `UNIQUE_VIOLATION`. */
10
+ export declare function linkAccount(userId: string, method: string, subject: string): Promise<void>;
11
+ /** The user `subject` of `method` signs in as, if any. */
12
+ export declare function accountOf(method: string, subject: string): Promise<{
13
+ readonly userId: string;
14
+ } | undefined>;
15
+ /** The subject `userId` is known by to `method`, if linked (D-117). */
16
+ export declare function subjectOf(userId: string, method: string): Promise<string | undefined>;
17
+ /** Unlinks every account of a user. */
18
+ export declare function unlinkAccounts(userId: string): Promise<void>;
19
+ export {};
20
+ //# sourceMappingURL=accounts.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,33 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ const M = { as: master };
3
+ /** auth's own model (D-110). */
4
+ export const AuthAccount = defineModel({
5
+ name: 'AuthAccount',
6
+ fields: (f) => ({
7
+ userId: f.string(),
8
+ method: f.string(),
9
+ subject: f.string(),
10
+ }),
11
+ indexes: [
12
+ { fields: ['method', 'subject'], unique: true },
13
+ { fields: ['userId'] },
14
+ ],
15
+ });
16
+ /** Links `userId` to `subject` of `method`; a taken pair is a `UNIQUE_VIOLATION`. */
17
+ export async function linkAccount(userId, method, subject) {
18
+ await AuthAccount.create({ data: { userId, method, subject } }, M);
19
+ }
20
+ /** The user `subject` of `method` signs in as, if any. */
21
+ export async function accountOf(method, subject) {
22
+ const [account] = await AuthAccount.findMany({ where: { method, subject }, take: 1 }, M);
23
+ return account === undefined ? undefined : { userId: account.userId };
24
+ }
25
+ /** The subject `userId` is known by to `method`, if linked (D-117). */
26
+ export async function subjectOf(userId, method) {
27
+ const [account] = await AuthAccount.findMany({ where: { userId, method }, take: 1 }, M);
28
+ return account?.subject;
29
+ }
30
+ /** Unlinks every account of a user. */
31
+ export async function unlinkAccounts(userId) {
32
+ await AuthAccount.deleteMany({ where: { userId } }, M);
33
+ }
@@ -0,0 +1,16 @@
1
+ import type { FieldShapes, IndexShape, ModelFields, ModelIndexes, ModelOf, ModelUse, PluginTypes } from 'ultimabase';
2
+ import type { Auth } from './plugin.ts';
3
+ type Uses = readonly ModelUse<string, PluginTypes>[];
4
+ /** The user model's `use`: auth first, then the developer's plugins. */
5
+ type WithAuth<U extends Uses> = readonly [ModelUse<'auth', Auth>, ...U];
6
+ /** The application's one user model (D-62). */
7
+ export declare function defineUser<const N extends string, const F extends FieldShapes, const U extends Uses = [], const X extends readonly IndexShape[] = []>(definition: {
8
+ readonly name: N;
9
+ /** The stored table's or collection's name (D-63). */
10
+ readonly dbName?: string;
11
+ readonly use?: U;
12
+ readonly fields: ModelFields<F, WithAuth<U>>;
13
+ readonly indexes?: ModelIndexes<X, F, WithAuth<U>>;
14
+ }): ModelOf<N, F, WithAuth<U>, X>;
15
+ export {};
16
+ //# sourceMappingURL=define-user.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"define-user.d.ts","sourceRoot":"","sources":["../src/define-user.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,WAAW,EACX,UAAU,EACV,WAAW,EACX,YAAY,EACZ,OAAO,EACP,QAAQ,EACR,WAAW,EACX,MAAM,YAAY,CAAA;AAEnB,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAA;AAGvC,KAAK,IAAI,GAAG,SAAS,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,EAAE,CAAA;AACpD,wEAAwE;AACxE,KAAK,QAAQ,CAAC,CAAC,SAAS,IAAI,IAAI,SAAS,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAA;AAEvE,+CAA+C;AAC/C,wBAAgB,UAAU,CACzB,KAAK,CAAC,CAAC,SAAS,MAAM,EACtB,KAAK,CAAC,CAAC,SAAS,WAAW,EAC3B,KAAK,CAAC,CAAC,SAAS,IAAI,GAAG,EAAE,EACzB,KAAK,CAAC,CAAC,SAAS,SAAS,UAAU,EAAE,GAAG,EAAE,EACzC,UAAU,EAAE;IACb,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IAChB,sDAAsD;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAChB,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;IAC5C,QAAQ,CAAC,OAAO,CAAC,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAA;CAClD,GAAG,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAShC"}
@@ -0,0 +1,13 @@
1
+ import { defineModel } from 'ultimabase';
2
+ import { authUse, userModels } from './plugin.js';
3
+ /** The application's one user model (D-62). */
4
+ export function defineUser(definition) {
5
+ const model = defineModel({
6
+ ...definition,
7
+ use: [authUse(), ...(definition.use ?? [])],
8
+ });
9
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- defineModel's own model, with auth in its use; an omitted `use` leaves `U` at its default only when nobody passes it (D-62).
10
+ const user = model;
11
+ userModels.set(model, user.nullable());
12
+ return user;
13
+ }
@@ -0,0 +1,12 @@
1
+ import type { FunctionShape, ModelQueries, OutputSpec } from 'ultimabase';
2
+ import type { Cookie } from './token.ts';
3
+ /** Each session's token, by the user row `identify` read for it (D-121). */
4
+ export declare const callerTokens: WeakMap<object, string>;
5
+ /** The user model's queries, and its output `User`, or `null`. */
6
+ export interface Users {
7
+ readonly queries: ModelQueries;
8
+ readonly output: OutputSpec;
9
+ }
10
+ /** `signOut` and `viewer`, public: each reads the caller only. */
11
+ export declare function authFunctions(cookie: Cookie, users: Users): readonly FunctionShape[];
12
+ //# sourceMappingURL=functions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"functions.d.ts","sourceRoot":"","sources":["../src/functions.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AAGzE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,4EAA4E;AAC5E,eAAO,MAAM,YAAY,EAAE,OAAO,CAAC,MAAM,EAAE,MAAM,CAAiB,CAAA;AAElE,kEAAkE;AAClE,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,OAAO,EAAE,YAAY,CAAA;IAC9B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;CAC3B;AAED,kEAAkE;AAClE,wBAAgB,aAAa,CAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,KAAK,GACV,SAAS,aAAa,EAAE,CAuB1B"}
@@ -0,0 +1,25 @@
1
+ import { allow, defineFunction } from 'ultimabase';
2
+ import { closeSession } from './sessions.js';
3
+ /** Each session's token, by the user row `identify` read for it (D-121). */
4
+ export const callerTokens = new WeakMap();
5
+ /** `signOut` and `viewer`, public: each reads the caller only. */
6
+ export function authFunctions(cookie, users) {
7
+ const signOut = defineFunction({
8
+ name: 'signOut',
9
+ run: async ({ caller, client }) => {
10
+ const token = caller.kind === 'user' ? callerTokens.get(caller.user) : undefined;
11
+ if (token !== undefined)
12
+ await closeSession(token);
13
+ client?.cookies.clear(cookie.name, cookie.options);
14
+ },
15
+ }).with({ access: allow.public });
16
+ const viewer = defineFunction({
17
+ name: 'viewer',
18
+ query: true,
19
+ output: users.output,
20
+ run: async ({ caller }) => caller.kind === 'user'
21
+ ? users.queries.findUnique({ where: { id: caller.user.id } }, { as: caller })
22
+ : null,
23
+ }).with({ access: allow.public });
24
+ return [signOut, viewer];
25
+ }
@@ -0,0 +1,5 @@
1
+ export { defineUser } from './define-user.ts';
2
+ export type { Auth, AuthApi, AuthCommands, Opened } from './plugin.ts';
3
+ export { authPlugin } from './plugin.ts';
4
+ export { currentUser, isAuthenticated, isOwner, isSelf, signedIn, } from './rules.ts';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimabase/auth` (D-112, D-113): the user model, sessions, linked
2
+ // accounts, the caller of each request, and the rules that read it.
3
+ export { defineUser } from './define-user.js';
4
+ export { authPlugin } from './plugin.js';
5
+ export { currentUser, isAuthenticated, isOwner, isSelf, signedIn, } from './rules.js';
@@ -0,0 +1,45 @@
1
+ import type { Http } from '@ultimabase/http';
2
+ import type { Client, Identity, OutputSpec, PluginOf, PluginTypes, UseOf } from 'ultimabase';
3
+ import type { Opened } from './sessions.ts';
4
+ export type { Opened } from './sessions.ts';
5
+ /** What auth offers the sign-in methods (D-33, D-112, D-113). */
6
+ export interface AuthApi {
7
+ /**
8
+ * Opens a session for `userId`, and sets its cookie when a transport's
9
+ * `client` is given, ending the session the cookie it replaces held.
10
+ */
11
+ signIn(userId: string, client?: Client): Promise<Opened>;
12
+ /** Ends every session of a user. */
13
+ revokeSessions(userId: string): Promise<void>;
14
+ /** Links `userId` to `subject` of `method`; a taken pair is a `UNIQUE_VIOLATION`. */
15
+ linkAccount(userId: string, method: string, subject: string): Promise<void>;
16
+ /** The user `subject` of `method` signs in as, if any. */
17
+ accountOf(method: string, subject: string): Promise<{
18
+ readonly userId: string;
19
+ } | undefined>;
20
+ /** The subject `userId` is known by to `method`, if linked (D-117). */
21
+ subjectOf(userId: string, method: string): Promise<string | undefined>;
22
+ }
23
+ /** auth's commands on the user model (D-113). */
24
+ export interface AuthCommands {
25
+ /** Ends every session of the user, as Master or as the user. */
26
+ revokeSessions(where: {
27
+ readonly id: string;
28
+ }, options?: {
29
+ readonly as?: Identity;
30
+ }): Promise<void>;
31
+ }
32
+ /** auth's types (D-57). */
33
+ export interface Auth extends PluginTypes {
34
+ readonly name: 'auth';
35
+ readonly requires: Http;
36
+ readonly api: AuthApi;
37
+ readonly commands: AuthCommands;
38
+ }
39
+ /** The models `defineUser` returned, each with its output `User`, or `null`. */
40
+ export declare const userModels: WeakMap<object, OutputSpec>;
41
+ /** Sessions and the caller of each request; it requires `httpPlugin()`. */
42
+ export declare const authPlugin: PluginOf<Auth>;
43
+ /** auth's entry in the user model's `use`, which `defineUser` adds. */
44
+ export declare const authUse: UseOf<Auth>;
45
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +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"}
package/dist/plugin.js ADDED
@@ -0,0 +1,124 @@
1
+ import { httpPlugin } from '@ultimabase/http';
2
+ import { definePlugin, isLocalHost, isMaster, master, PublicError, Refused, } from 'ultimabase';
3
+ import manifest from '../package.json' with { type: 'json' };
4
+ import { accountOf, AuthAccount, linkAccount, subjectOf, unlinkAccounts, } from './accounts.js';
5
+ import { authFunctions, callerTokens } from './functions.js';
6
+ import { AuthSession, closeSession, openSession, revokeSessions, sessionOf, } from './sessions.js';
7
+ import { bearerOf, cookieAge, cookieOf, cookieTokenOf } from './token.js';
8
+ /** The models `defineUser` returned, each with its output `User`, or `null`. */
9
+ export const userModels = new WeakMap();
10
+ const states = new WeakMap();
11
+ /** The contexts whose insecure cookie was warned about (D-115). */
12
+ const warned = new WeakSet();
13
+ const M = { as: master };
14
+ function stateOf(context) {
15
+ const state = states.get(context);
16
+ if (state === undefined)
17
+ throw new Error('auth has not started.');
18
+ return state;
19
+ }
20
+ /** The one user model among the application's models (D-62). */
21
+ function usersOf(models) {
22
+ const [first, second] = models.flatMap(({ queries }) => {
23
+ const output = userModels.get(queries);
24
+ return output === undefined ? [] : [{ queries, output }];
25
+ });
26
+ if (first === undefined) {
27
+ throw new Refused('authPlugin() needs a user model: declare it with defineUser.');
28
+ }
29
+ if (second !== undefined) {
30
+ throw new Refused('models lists two models defineUser returned: an application has one user model.');
31
+ }
32
+ return first;
33
+ }
34
+ /** 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);
37
+ if (session === undefined)
38
+ return undefined;
39
+ // A transport's queries are untyped; this is the user model's row.
40
+ const user = await users.queries.findUnique({ where: { id: session.userId } }, M);
41
+ if (user === null)
42
+ return undefined;
43
+ expires(session.expiresAt);
44
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model is the one the application registers (D-62, D-80).
45
+ const caller = { kind: 'user', user: user };
46
+ // The completed caller keeps this row (D-121).
47
+ callerTokens.set(caller.user, token);
48
+ return caller;
49
+ }
50
+ const auth = definePlugin({
51
+ name: 'auth',
52
+ manifest,
53
+ requires: { http: httpPlugin },
54
+ models: [AuthSession, AuthAccount],
55
+ hooks: {
56
+ // The user's sessions and accounts go with them (D-111, D-113).
57
+ afterDelete: async ({ record }) => {
58
+ await revokeSessions(record.id);
59
+ await unlinkAccounts(record.id);
60
+ },
61
+ },
62
+ commands: () => ({
63
+ 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
+ }
69
+ await revokeSessions(id);
70
+ },
71
+ }),
72
+ // Its own cookie and user model: this context is not the API's.
73
+ functions: ({ models }) => authFunctions(cookieOf(), usersOf(models)),
74
+ // Built before any plugin starts, so before the server listens.
75
+ api: (context) => {
76
+ const cookie = cookieOf();
77
+ states.set(context, { cookie, users: usersOf(context.models) });
78
+ return {
79
+ signIn: async (userId, client) => {
80
+ // The cookie it replaces would leave a session nobody can end (D-115).
81
+ const replaced = client?.cookies.get(cookie.name);
82
+ if (replaced !== undefined)
83
+ await closeSession(replaced);
84
+ const opened = await openSession(userId);
85
+ client?.cookies.set(cookie.name, opened.token, {
86
+ ...cookie.options,
87
+ maxAge: cookieAge,
88
+ });
89
+ return opened;
90
+ },
91
+ revokeSessions,
92
+ linkAccount,
93
+ accountOf,
94
+ subjectOf,
95
+ };
96
+ },
97
+ identify: async (request, context) => {
98
+ const { cookie, users } = stateOf(context);
99
+ const { hostname } = new URL(request.url);
100
+ if (!cookie.options.secure &&
101
+ !warned.has(context) &&
102
+ !isLocalHost(hostname)) {
103
+ warned.add(context);
104
+ context.logger.warn(`auth serves ${hostname} a session cookie without Secure: set NODE_ENV=production.`);
105
+ }
106
+ const bearer = bearerOf(request);
107
+ if (bearer !== undefined) {
108
+ const caller = await callerOf(bearer, users, context.expires);
109
+ if (caller !== undefined)
110
+ return caller;
111
+ throw new PublicError('UNAUTHENTICATED', 'The session expired or was revoked: sign in again.');
112
+ }
113
+ // A stale cookie is anonymous: it must not lock a browser out of
114
+ // signing in again (D-112).
115
+ const token = cookieTokenOf(request, cookie);
116
+ if (token === undefined)
117
+ return undefined;
118
+ return callerOf(token, users, context.expires);
119
+ },
120
+ });
121
+ /** Sessions and the caller of each request; it requires `httpPlugin()`. */
122
+ export const authPlugin = auth.plugin;
123
+ /** auth's entry in the user model's `use`, which `defineUser` adds. */
124
+ export const authUse = auth.use;
@@ -0,0 +1,84 @@
1
+ import type { Caller, CallerRule, Register, UserOf } from 'ultimabase';
2
+ /** The rule "the caller is signed in" (D-10). */
3
+ export declare const isAuthenticated: CallerRule;
4
+ /**
5
+ * A rule for signed-in callers only, which receives `user` (D-79); it is
6
+ * `false` for anyone else. Its facts come from where it is used, as `anyOf`'s
7
+ * do; a read rule may still return a filter.
8
+ */
9
+ export declare function signedIn<Facts extends {
10
+ readonly caller: Caller;
11
+ }, R extends boolean | object>(rule: (facts: Facts & {
12
+ readonly user: UserOf<Register>;
13
+ }) => R): (facts: Facts) => R | false;
14
+ type Ref = {
15
+ readonly id: string;
16
+ } | null;
17
+ type Connected = {
18
+ readonly connect: {
19
+ readonly id: string;
20
+ };
21
+ };
22
+ type Keys<V, Shape> = {
23
+ [P in keyof V]-?: NonNullable<V[P]> extends Shape ? P : never;
24
+ }[keyof V];
25
+ /** The relations a write may connect, which may also create (D-128). */
26
+ type Connects<V> = {
27
+ [P in keyof V]-?: [Extract<NonNullable<V[P]>, Connected>] extends [never] ? never : P;
28
+ }[keyof V];
29
+ /**
30
+ * The record's `one` relations, those a create connects, or any name outside
31
+ * a model's rules.
32
+ */
33
+ type OwnerKey<Facts> = [Facts] extends [never] ? string : Facts extends {
34
+ readonly record: infer R;
35
+ } ? Keys<R, Ref> : Facts extends {
36
+ readonly where: unknown;
37
+ } ? 'a read rule has no record to own' : Facts extends {
38
+ readonly value: infer V;
39
+ } ? Connects<V> : string;
40
+ /** The facts it needs; a read rule's are refused on the name alone. */
41
+ type Owned<Facts, K extends string> = Facts extends {
42
+ readonly where: unknown;
43
+ } ? Facts : Facts extends {
44
+ readonly record: unknown;
45
+ } ? Facts & {
46
+ readonly record: {
47
+ readonly [P in K]: Ref;
48
+ };
49
+ } : Facts extends {
50
+ readonly value: unknown;
51
+ } ? Facts : Facts & {
52
+ readonly record: {
53
+ readonly [P in K]: Ref;
54
+ };
55
+ };
56
+ /**
57
+ * The rule "the caller is the user this `one` relation points to, before and
58
+ * after the write" (D-79): the stored key (D-77) when there is one, and the
59
+ * value written, which may only connect the caller. A create that does not
60
+ * write the relation leaves it to its default. Its facts come from where it
61
+ * is used, so a wrong name fails on the name.
62
+ */
63
+ export declare function isOwner<Facts extends {
64
+ readonly caller: Caller;
65
+ }, const K extends OwnerKey<Facts>>(field: K): (facts: Owned<Facts, K>) => boolean;
66
+ /** The rule "the caller is this record" (D-62). */
67
+ export declare function isSelf(facts: {
68
+ readonly caller: Caller;
69
+ readonly record: {
70
+ readonly id: string;
71
+ };
72
+ }): boolean;
73
+ /**
74
+ * The default "whoever creates the record" (D-53, D-62): the signed-in
75
+ * caller's `{ id }`, no value otherwise. Its declared return type spares the
76
+ * user model's own defaults the annotation.
77
+ */
78
+ export declare function currentUser(facts: {
79
+ readonly caller: Caller;
80
+ }): {
81
+ readonly id: string;
82
+ } | undefined;
83
+ export {};
84
+ //# sourceMappingURL=rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../src/rules.ts"],"names":[],"mappings":"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"}
package/dist/rules.js ADDED
@@ -0,0 +1,48 @@
1
+ /** The rule "the caller is signed in" (D-10). */
2
+ export const isAuthenticated = ({ caller }) => caller.kind === 'user';
3
+ /**
4
+ * A rule for signed-in callers only, which receives `user` (D-79); it is
5
+ * `false` for anyone else. Its facts come from where it is used, as `anyOf`'s
6
+ * do; a read rule may still return a filter.
7
+ */
8
+ export function signedIn(rule) {
9
+ return (facts) => facts.caller.kind === 'user'
10
+ ? rule({ ...facts, user: facts.caller.user })
11
+ : false;
12
+ }
13
+ const get = (value, key) => typeof value === 'object' && value !== null
14
+ ? Reflect.get(value, key)
15
+ : undefined;
16
+ /**
17
+ * The rule "the caller is the user this `one` relation points to, before and
18
+ * after the write" (D-79): the stored key (D-77) when there is one, and the
19
+ * value written, which may only connect the caller. A create that does not
20
+ * write the relation leaves it to its default. Its facts come from where it
21
+ * is used, so a wrong name fails on the name.
22
+ */
23
+ export function isOwner(field) {
24
+ return (facts) => {
25
+ const { caller } = facts;
26
+ if (caller.kind !== 'user')
27
+ return false;
28
+ const record = get(facts, 'record');
29
+ if (record !== undefined &&
30
+ get(get(record, field), 'id') !== caller.user.id)
31
+ return false;
32
+ const written = get(get(facts, 'value'), field);
33
+ return (written === undefined ||
34
+ get(get(written, 'connect'), 'id') === caller.user.id);
35
+ };
36
+ }
37
+ /** The rule "the caller is this record" (D-62). */
38
+ export function isSelf(facts) {
39
+ return (facts.caller.kind === 'user' && facts.caller.user.id === facts.record.id);
40
+ }
41
+ /**
42
+ * The default "whoever creates the record" (D-53, D-62): the signed-in
43
+ * caller's `{ id }`, no value otherwise. Its declared return type spares the
44
+ * user model's own defaults the annotation.
45
+ */
46
+ export function currentUser(facts) {
47
+ return facts.caller.kind === 'user' ? { id: facts.caller.user.id } : undefined;
48
+ }
@@ -0,0 +1,32 @@
1
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
2
+ type SessionFields = {
3
+ readonly tokenHash: Field<string, {
4
+ readonly unique: true;
5
+ }>;
6
+ readonly userId: Field<string, ScalarOptions<string>>;
7
+ readonly expiresAt: Field<Date, ScalarOptions<Date>>;
8
+ };
9
+ /** auth's own model (D-110). */
10
+ export declare const AuthSession: ModelOf<'AuthSession', SessionFields, []>;
11
+ /** A token shown once, and the session it opens. */
12
+ export interface Opened {
13
+ readonly token: string;
14
+ readonly expiresAt: Date;
15
+ }
16
+ /** Opens a session for `userId`, ending every expired one (D-115). */
17
+ export declare function openSession(userId: string): Promise<Opened>;
18
+ /**
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.
22
+ */
23
+ export declare function sessionOf(token: string): Promise<{
24
+ readonly userId: string;
25
+ readonly expiresAt: Date;
26
+ } | undefined>;
27
+ /** Ends the session `token` opened, if any. */
28
+ export declare function closeSession(token: string): Promise<void>;
29
+ /** Ends every session of a user. */
30
+ export declare function revokeSessions(userId: string): Promise<void>;
31
+ export {};
32
+ //# sourceMappingURL=sessions.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,72 @@
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).
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { defineModel, master, PublicError } from 'ultimabase';
6
+ const M = { as: master };
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;
13
+ /** auth's own model (D-110). */
14
+ export const AuthSession = defineModel({
15
+ name: 'AuthSession',
16
+ fields: (f) => ({
17
+ tokenHash: f.string({ unique: true }),
18
+ userId: f.string(),
19
+ expiresAt: f.dateTime(),
20
+ }),
21
+ indexes: [{ fields: ['userId'] }, { fields: ['expiresAt'] }],
22
+ });
23
+ const digest = (token) => createHash('sha256').update(token).digest('base64url');
24
+ /** Opens a session for `userId`, ending every expired one (D-115). */
25
+ export async function openSession(userId) {
26
+ const now = Date.now();
27
+ await AuthSession.deleteMany({ where: { expiresAt: { lte: new Date(now) } } }, M);
28
+ const token = randomBytes(32).toString('base64url');
29
+ const expiresAt = new Date(now + lifetime);
30
+ await AuthSession.create({ data: { tokenHash: digest(token), userId, expiresAt } }, M);
31
+ return { token, expiresAt };
32
+ }
33
+ /**
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.
37
+ */
38
+ export async function sessionOf(token) {
39
+ const tokenHash = digest(token);
40
+ const session = await AuthSession.findUnique({ where: { tokenHash } }, M);
41
+ if (session === null)
42
+ return undefined;
43
+ const now = Date.now();
44
+ const left = session.expiresAt.getTime() - now;
45
+ if (left <= 0) {
46
+ await AuthSession.deleteMany({ where: { tokenHash } }, M);
47
+ return undefined;
48
+ }
49
+ const end = Math.min(now + lifetime, session.createdAt.getTime() + longest);
50
+ if (left < renewal && end > session.expiresAt.getTime()) {
51
+ try {
52
+ await AuthSession.update({ where: { tokenHash }, data: { expiresAt: new Date(end) } }, M);
53
+ }
54
+ catch (error) {
55
+ // Signed out between the read and the renewal: the session ended.
56
+ if (error instanceof PublicError && error.code === 'NOT_FOUND') {
57
+ return undefined;
58
+ }
59
+ throw error;
60
+ }
61
+ return { userId: session.userId, expiresAt: new Date(end) };
62
+ }
63
+ return session;
64
+ }
65
+ /** Ends the session `token` opened, if any. */
66
+ export async function closeSession(token) {
67
+ await AuthSession.deleteMany({ where: { tokenHash: digest(token) } }, M);
68
+ }
69
+ /** Ends every session of a user. */
70
+ export async function revokeSessions(userId) {
71
+ await AuthSession.deleteMany({ where: { userId } }, M);
72
+ }
@@ -0,0 +1,19 @@
1
+ import type { CookieOptions } from 'ultimabase';
2
+ /** The longest a browser keeps a cookie: 400 days. */
3
+ export declare const cookieAge: number;
4
+ /** The cookie's name and attributes, read once when the application starts. */
5
+ export interface Cookie {
6
+ readonly name: string;
7
+ /** Its attributes but `maxAge`, the same to set and to clear it. */
8
+ readonly options: CookieOptions;
9
+ }
10
+ /** Secure and `__Host-` in production, so it works on `http://localhost`. */
11
+ export declare function cookieOf(): Cookie;
12
+ /**
13
+ * The token the `Authorization` header sends, `''` for a Bearer without one,
14
+ * `undefined` without a Bearer header, which another plugin may read.
15
+ */
16
+ export declare function bearerOf(request: Request): string | undefined;
17
+ /** The token the cookie sends, if any. */
18
+ export declare function cookieTokenOf(request: Request, cookie: Cookie): string | undefined;
19
+ //# sourceMappingURL=token.d.ts.map
@@ -0,0 +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"}