@ultimabase/auth 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +4 -2
- package/dist/accounts.d.ts +20 -0
- package/dist/accounts.d.ts.map +1 -0
- package/dist/accounts.js +33 -0
- package/dist/define-user.d.ts +16 -0
- package/dist/define-user.d.ts.map +1 -0
- package/dist/define-user.js +13 -0
- package/dist/functions.d.ts +12 -0
- package/dist/functions.d.ts.map +1 -0
- package/dist/functions.js +25 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/plugin.d.ts +45 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +124 -0
- package/dist/rules.d.ts +84 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +48 -0
- package/dist/sessions.d.ts +32 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +72 -0
- package/dist/token.d.ts +19 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +32 -0
- package/package.json +46 -3
- package/src/accounts.ts +65 -0
- package/src/define-user.ts +42 -0
- package/src/functions.ts +44 -0
- package/src/index.ts +12 -0
- package/src/plugin.ts +229 -0
- package/src/rules.ts +112 -0
- package/src/sessions.ts +103 -0
- package/src/token.ts +47 -0
package/dist/token.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The longest a browser keeps a cookie: 400 days. */
|
|
2
|
+
export const cookieAge = 400 * 24 * 60 * 60;
|
|
3
|
+
/** Secure and `__Host-` in production, so it works on `http://localhost`. */
|
|
4
|
+
export function cookieOf() {
|
|
5
|
+
const secure = process.env['NODE_ENV'] === 'production';
|
|
6
|
+
const name = secure ? '__Host-ultimabase_session' : 'ultimabase_session';
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
options: { httpOnly: true, sameSite: 'Lax', path: '/', secure },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
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 function bearerOf(request) {
|
|
17
|
+
const header = request.headers.get('authorization');
|
|
18
|
+
const match = header === null ? null : /^bearer(?: +(.*))?$/i.exec(header);
|
|
19
|
+
if (match === null)
|
|
20
|
+
return undefined;
|
|
21
|
+
return match[1]?.trim() ?? '';
|
|
22
|
+
}
|
|
23
|
+
/** The token the cookie sends, if any. */
|
|
24
|
+
export function cookieTokenOf(request, cookie) {
|
|
25
|
+
const header = request.headers.get('cookie') ?? '';
|
|
26
|
+
for (const pair of header.split(';')) {
|
|
27
|
+
const [name, ...value] = pair.split('=');
|
|
28
|
+
if (name?.trim() === cookie.name)
|
|
29
|
+
return value.join('=').trim();
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimabase/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Authentication for UltimaBase: the user model, sessions and the caller of each request.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://ultimabase.dev/docs/auth",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/MrEkinox/ultimabase.git",
|
|
11
|
+
"directory": "packages/auth"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24.7.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"!src/test"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@ultimabase/http": "^0.1.1",
|
|
35
|
+
"ultimabase": "^0.1.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/pg": "8.23.1",
|
|
39
|
+
"@ultimabase/graphql": "0.1.1",
|
|
40
|
+
"@ultimabase/http": "0.1.1",
|
|
41
|
+
"@ultimabase/postgres": "0.1.1",
|
|
42
|
+
"@ultimabase/testing": "0.1.1",
|
|
43
|
+
"graphql": "17.0.2",
|
|
44
|
+
"pg": "8.23.1",
|
|
45
|
+
"ultimabase": "0.1.1",
|
|
46
|
+
"vite": "8.3.4",
|
|
47
|
+
"vitest": "5.0.5"
|
|
48
|
+
}
|
|
6
49
|
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Linked accounts (D-113): one row per way a user signs in, unique on its
|
|
2
|
+
// method and the subject that method knows the user by.
|
|
3
|
+
import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
|
|
6
|
+
const M = { as: master }
|
|
7
|
+
|
|
8
|
+
type AccountFields = {
|
|
9
|
+
readonly userId: Field<string, ScalarOptions<string>>
|
|
10
|
+
readonly method: Field<string, ScalarOptions<string>>
|
|
11
|
+
readonly subject: Field<string, ScalarOptions<string>>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** auth's own model (D-110). */
|
|
15
|
+
export const AuthAccount: ModelOf<'AuthAccount', AccountFields, []> =
|
|
16
|
+
defineModel({
|
|
17
|
+
name: 'AuthAccount',
|
|
18
|
+
fields: (f) => ({
|
|
19
|
+
userId: f.string(),
|
|
20
|
+
method: f.string(),
|
|
21
|
+
subject: f.string(),
|
|
22
|
+
}),
|
|
23
|
+
indexes: [
|
|
24
|
+
{ fields: ['method', 'subject'], unique: true },
|
|
25
|
+
{ fields: ['userId'] },
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** Links `userId` to `subject` of `method`; a taken pair is a `UNIQUE_VIOLATION`. */
|
|
30
|
+
export async function linkAccount(
|
|
31
|
+
userId: string,
|
|
32
|
+
method: string,
|
|
33
|
+
subject: string,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
await AuthAccount.create({ data: { userId, method, subject } }, M)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The user `subject` of `method` signs in as, if any. */
|
|
39
|
+
export async function accountOf(
|
|
40
|
+
method: string,
|
|
41
|
+
subject: string,
|
|
42
|
+
): Promise<{ readonly userId: string } | undefined> {
|
|
43
|
+
const [account] = await AuthAccount.findMany(
|
|
44
|
+
{ where: { method, subject }, take: 1 },
|
|
45
|
+
M,
|
|
46
|
+
)
|
|
47
|
+
return account === undefined ? undefined : { userId: account.userId }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The subject `userId` is known by to `method`, if linked (D-117). */
|
|
51
|
+
export async function subjectOf(
|
|
52
|
+
userId: string,
|
|
53
|
+
method: string,
|
|
54
|
+
): Promise<string | undefined> {
|
|
55
|
+
const [account] = await AuthAccount.findMany(
|
|
56
|
+
{ where: { userId, method }, take: 1 },
|
|
57
|
+
M,
|
|
58
|
+
)
|
|
59
|
+
return account?.subject
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Unlinks every account of a user. */
|
|
63
|
+
export async function unlinkAccounts(userId: string): Promise<void> {
|
|
64
|
+
await AuthAccount.deleteMany({ where: { userId } }, M)
|
|
65
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// auth's `define<Kind>` entry (D-13, D-62): the application's one user model,
|
|
2
|
+
// an ordinary model with auth first in its `use`.
|
|
3
|
+
import type {
|
|
4
|
+
FieldShapes,
|
|
5
|
+
IndexShape,
|
|
6
|
+
ModelFields,
|
|
7
|
+
ModelIndexes,
|
|
8
|
+
ModelOf,
|
|
9
|
+
ModelUse,
|
|
10
|
+
PluginTypes,
|
|
11
|
+
} from 'ultimabase'
|
|
12
|
+
import { defineModel } from 'ultimabase'
|
|
13
|
+
import type { Auth } from './plugin.ts'
|
|
14
|
+
import { authUse, userModels } from './plugin.ts'
|
|
15
|
+
|
|
16
|
+
type Uses = readonly ModelUse<string, PluginTypes>[]
|
|
17
|
+
/** The user model's `use`: auth first, then the developer's plugins. */
|
|
18
|
+
type WithAuth<U extends Uses> = readonly [ModelUse<'auth', Auth>, ...U]
|
|
19
|
+
|
|
20
|
+
/** The application's one user model (D-62). */
|
|
21
|
+
export function defineUser<
|
|
22
|
+
const N extends string,
|
|
23
|
+
const F extends FieldShapes,
|
|
24
|
+
const U extends Uses = [],
|
|
25
|
+
const X extends readonly IndexShape[] = [],
|
|
26
|
+
>(definition: {
|
|
27
|
+
readonly name: N
|
|
28
|
+
/** The stored table's or collection's name (D-63). */
|
|
29
|
+
readonly dbName?: string
|
|
30
|
+
readonly use?: U
|
|
31
|
+
readonly fields: ModelFields<F, WithAuth<U>>
|
|
32
|
+
readonly indexes?: ModelIndexes<X, F, WithAuth<U>>
|
|
33
|
+
}): ModelOf<N, F, WithAuth<U>, X> {
|
|
34
|
+
const model: object = defineModel({
|
|
35
|
+
...definition,
|
|
36
|
+
use: [authUse(), ...(definition.use ?? [])],
|
|
37
|
+
})
|
|
38
|
+
// 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).
|
|
39
|
+
const user = model as ModelOf<N, F, WithAuth<U>, X>
|
|
40
|
+
userModels.set(model, user.nullable())
|
|
41
|
+
return user
|
|
42
|
+
}
|
package/src/functions.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// auth's functions (D-113): `signOut`, which ends the request's session and
|
|
2
|
+
// clears its cookie, and `viewer`, the caller's user read again as the caller.
|
|
3
|
+
import type { FunctionShape, ModelQueries, OutputSpec } from 'ultimabase'
|
|
4
|
+
import { allow, defineFunction } from 'ultimabase'
|
|
5
|
+
import { closeSession } from './sessions.ts'
|
|
6
|
+
import type { Cookie } from './token.ts'
|
|
7
|
+
|
|
8
|
+
/** Each session's token, by the user row `identify` read for it (D-121). */
|
|
9
|
+
export const callerTokens: WeakMap<object, string> = new WeakMap()
|
|
10
|
+
|
|
11
|
+
/** The user model's queries, and its output `User`, or `null`. */
|
|
12
|
+
export interface Users {
|
|
13
|
+
readonly queries: ModelQueries
|
|
14
|
+
readonly output: OutputSpec
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `signOut` and `viewer`, public: each reads the caller only. */
|
|
18
|
+
export function authFunctions(
|
|
19
|
+
cookie: Cookie,
|
|
20
|
+
users: Users,
|
|
21
|
+
): readonly FunctionShape[] {
|
|
22
|
+
const signOut = defineFunction({
|
|
23
|
+
name: 'signOut',
|
|
24
|
+
run: async ({ caller, client }) => {
|
|
25
|
+
const token =
|
|
26
|
+
caller.kind === 'user' ? callerTokens.get(caller.user) : undefined
|
|
27
|
+
if (token !== undefined) await closeSession(token)
|
|
28
|
+
client?.cookies.clear(cookie.name, cookie.options)
|
|
29
|
+
},
|
|
30
|
+
}).with({ access: allow.public })
|
|
31
|
+
const viewer = defineFunction({
|
|
32
|
+
name: 'viewer',
|
|
33
|
+
query: true,
|
|
34
|
+
output: users.output,
|
|
35
|
+
run: async ({ caller }) =>
|
|
36
|
+
caller.kind === 'user'
|
|
37
|
+
? users.queries.findUnique(
|
|
38
|
+
{ where: { id: caller.user.id } },
|
|
39
|
+
{ as: caller },
|
|
40
|
+
)
|
|
41
|
+
: null,
|
|
42
|
+
}).with({ access: allow.public })
|
|
43
|
+
return [signOut, viewer]
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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.ts'
|
|
4
|
+
export type { Auth, AuthApi, AuthCommands, Opened } from './plugin.ts'
|
|
5
|
+
export { authPlugin } from './plugin.ts'
|
|
6
|
+
export {
|
|
7
|
+
currentUser,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
isOwner,
|
|
10
|
+
isSelf,
|
|
11
|
+
signedIn,
|
|
12
|
+
} from './rules.ts'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// `authPlugin()` (D-112, D-113): the caller of each request from its
|
|
2
|
+
// session, the API sign-in methods open sessions and link accounts with, and
|
|
3
|
+
// its functions and command.
|
|
4
|
+
import type { Http } from '@ultimabase/http'
|
|
5
|
+
import { httpPlugin } from '@ultimabase/http'
|
|
6
|
+
import type {
|
|
7
|
+
Client,
|
|
8
|
+
Identity,
|
|
9
|
+
OutputSpec,
|
|
10
|
+
PluginOf,
|
|
11
|
+
PluginTypes,
|
|
12
|
+
Register,
|
|
13
|
+
UseOf,
|
|
14
|
+
UserOf,
|
|
15
|
+
} from 'ultimabase'
|
|
16
|
+
import {
|
|
17
|
+
definePlugin,
|
|
18
|
+
isLocalHost,
|
|
19
|
+
isMaster,
|
|
20
|
+
master,
|
|
21
|
+
PublicError,
|
|
22
|
+
Refused,
|
|
23
|
+
} from 'ultimabase'
|
|
24
|
+
import manifest from '../package.json' with { type: 'json' }
|
|
25
|
+
import {
|
|
26
|
+
accountOf,
|
|
27
|
+
AuthAccount,
|
|
28
|
+
linkAccount,
|
|
29
|
+
subjectOf,
|
|
30
|
+
unlinkAccounts,
|
|
31
|
+
} from './accounts.ts'
|
|
32
|
+
import type { Users } from './functions.ts'
|
|
33
|
+
import { authFunctions, callerTokens } from './functions.ts'
|
|
34
|
+
import type { Opened } from './sessions.ts'
|
|
35
|
+
import {
|
|
36
|
+
AuthSession,
|
|
37
|
+
closeSession,
|
|
38
|
+
openSession,
|
|
39
|
+
revokeSessions,
|
|
40
|
+
sessionOf,
|
|
41
|
+
} from './sessions.ts'
|
|
42
|
+
import type { Cookie } from './token.ts'
|
|
43
|
+
import { bearerOf, cookieAge, cookieOf, cookieTokenOf } from './token.ts'
|
|
44
|
+
|
|
45
|
+
export type { Opened } from './sessions.ts'
|
|
46
|
+
|
|
47
|
+
/** What auth offers the sign-in methods (D-33, D-112, D-113). */
|
|
48
|
+
export interface AuthApi {
|
|
49
|
+
/**
|
|
50
|
+
* Opens a session for `userId`, and sets its cookie when a transport's
|
|
51
|
+
* `client` is given, ending the session the cookie it replaces held.
|
|
52
|
+
*/
|
|
53
|
+
signIn(userId: string, client?: Client): Promise<Opened>
|
|
54
|
+
/** Ends every session of a user. */
|
|
55
|
+
revokeSessions(userId: string): Promise<void>
|
|
56
|
+
/** Links `userId` to `subject` of `method`; a taken pair is a `UNIQUE_VIOLATION`. */
|
|
57
|
+
linkAccount(userId: string, method: string, subject: string): Promise<void>
|
|
58
|
+
/** The user `subject` of `method` signs in as, if any. */
|
|
59
|
+
accountOf(
|
|
60
|
+
method: string,
|
|
61
|
+
subject: string,
|
|
62
|
+
): Promise<{ readonly userId: string } | undefined>
|
|
63
|
+
/** The subject `userId` is known by to `method`, if linked (D-117). */
|
|
64
|
+
subjectOf(userId: string, method: string): Promise<string | undefined>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** auth's commands on the user model (D-113). */
|
|
68
|
+
export interface AuthCommands {
|
|
69
|
+
/** Ends every session of the user, as Master or as the user. */
|
|
70
|
+
revokeSessions(
|
|
71
|
+
where: { readonly id: string },
|
|
72
|
+
options?: { readonly as?: Identity },
|
|
73
|
+
): Promise<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** auth's types (D-57). */
|
|
77
|
+
export interface Auth extends PluginTypes {
|
|
78
|
+
readonly name: 'auth'
|
|
79
|
+
readonly requires: Http
|
|
80
|
+
readonly api: AuthApi
|
|
81
|
+
readonly commands: AuthCommands
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The models `defineUser` returned, each with its output `User`, or `null`. */
|
|
85
|
+
export const userModels: WeakMap<object, OutputSpec> = new WeakMap()
|
|
86
|
+
|
|
87
|
+
/** What each running application's auth reads, by its context. */
|
|
88
|
+
interface State {
|
|
89
|
+
readonly cookie: Cookie
|
|
90
|
+
readonly users: Users
|
|
91
|
+
}
|
|
92
|
+
const states = new WeakMap<object, State>()
|
|
93
|
+
/** The contexts whose insecure cookie was warned about (D-115). */
|
|
94
|
+
const warned = new WeakSet<object>()
|
|
95
|
+
|
|
96
|
+
const M = { as: master }
|
|
97
|
+
|
|
98
|
+
function stateOf(context: object): State {
|
|
99
|
+
const state = states.get(context)
|
|
100
|
+
if (state === undefined) throw new Error('auth has not started.')
|
|
101
|
+
return state
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The one user model among the application's models (D-62). */
|
|
105
|
+
function usersOf(
|
|
106
|
+
models: readonly { readonly queries: Users['queries'] }[],
|
|
107
|
+
): Users {
|
|
108
|
+
const [first, second] = models.flatMap(({ queries }) => {
|
|
109
|
+
const output = userModels.get(queries)
|
|
110
|
+
return output === undefined ? [] : [{ queries, output }]
|
|
111
|
+
})
|
|
112
|
+
if (first === undefined) {
|
|
113
|
+
throw new Refused(
|
|
114
|
+
'authPlugin() needs a user model: declare it with defineUser.',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
if (second !== undefined) {
|
|
118
|
+
throw new Refused(
|
|
119
|
+
'models lists two models defineUser returned: an application has one user model.',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return first
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A session token's caller, until it ends; `undefined` for an unknown one. */
|
|
126
|
+
async function callerOf(
|
|
127
|
+
token: string,
|
|
128
|
+
users: Users,
|
|
129
|
+
expires: (at: Date) => void,
|
|
130
|
+
): Promise<Identity | undefined> {
|
|
131
|
+
const session = await sessionOf(token)
|
|
132
|
+
if (session === undefined) return undefined
|
|
133
|
+
// A transport's queries are untyped; this is the user model's row.
|
|
134
|
+
const user: unknown = await users.queries.findUnique(
|
|
135
|
+
{ where: { id: session.userId } },
|
|
136
|
+
M,
|
|
137
|
+
)
|
|
138
|
+
if (user === null) return undefined
|
|
139
|
+
expires(session.expiresAt)
|
|
140
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the user model is the one the application registers (D-62, D-80).
|
|
141
|
+
const caller: Identity = { kind: 'user', user: user as UserOf<Register> }
|
|
142
|
+
// The completed caller keeps this row (D-121).
|
|
143
|
+
callerTokens.set(caller.user, token)
|
|
144
|
+
return caller
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const auth = definePlugin<Auth>({
|
|
148
|
+
name: 'auth',
|
|
149
|
+
manifest,
|
|
150
|
+
requires: { http: httpPlugin },
|
|
151
|
+
models: [AuthSession, AuthAccount],
|
|
152
|
+
hooks: {
|
|
153
|
+
// The user's sessions and accounts go with them (D-111, D-113).
|
|
154
|
+
afterDelete: async ({ record }) => {
|
|
155
|
+
await revokeSessions(record.id)
|
|
156
|
+
await unlinkAccounts(record.id)
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
commands: () => ({
|
|
160
|
+
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
|
+
}
|
|
169
|
+
await revokeSessions(id)
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
// Its own cookie and user model: this context is not the API's.
|
|
173
|
+
functions: ({ models }) => authFunctions(cookieOf(), usersOf(models)),
|
|
174
|
+
// Built before any plugin starts, so before the server listens.
|
|
175
|
+
api: (context) => {
|
|
176
|
+
const cookie = cookieOf()
|
|
177
|
+
states.set(context, { cookie, users: usersOf(context.models) })
|
|
178
|
+
return {
|
|
179
|
+
signIn: async (userId, client) => {
|
|
180
|
+
// The cookie it replaces would leave a session nobody can end (D-115).
|
|
181
|
+
const replaced = client?.cookies.get(cookie.name)
|
|
182
|
+
if (replaced !== undefined) await closeSession(replaced)
|
|
183
|
+
const opened = await openSession(userId)
|
|
184
|
+
client?.cookies.set(cookie.name, opened.token, {
|
|
185
|
+
...cookie.options,
|
|
186
|
+
maxAge: cookieAge,
|
|
187
|
+
})
|
|
188
|
+
return opened
|
|
189
|
+
},
|
|
190
|
+
revokeSessions,
|
|
191
|
+
linkAccount,
|
|
192
|
+
accountOf,
|
|
193
|
+
subjectOf,
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
identify: async (request, context) => {
|
|
197
|
+
const { cookie, users } = stateOf(context)
|
|
198
|
+
const { hostname } = new URL(request.url)
|
|
199
|
+
if (
|
|
200
|
+
!cookie.options.secure &&
|
|
201
|
+
!warned.has(context) &&
|
|
202
|
+
!isLocalHost(hostname)
|
|
203
|
+
) {
|
|
204
|
+
warned.add(context)
|
|
205
|
+
context.logger.warn(
|
|
206
|
+
`auth serves ${hostname} a session cookie without Secure: set NODE_ENV=production.`,
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
const bearer = bearerOf(request)
|
|
210
|
+
if (bearer !== undefined) {
|
|
211
|
+
const caller = await callerOf(bearer, users, context.expires)
|
|
212
|
+
if (caller !== undefined) return caller
|
|
213
|
+
throw new PublicError(
|
|
214
|
+
'UNAUTHENTICATED',
|
|
215
|
+
'The session expired or was revoked: sign in again.',
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
// A stale cookie is anonymous: it must not lock a browser out of
|
|
219
|
+
// signing in again (D-112).
|
|
220
|
+
const token = cookieTokenOf(request, cookie)
|
|
221
|
+
if (token === undefined) return undefined
|
|
222
|
+
return callerOf(token, users, context.expires)
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
/** Sessions and the caller of each request; it requires `httpPlugin()`. */
|
|
227
|
+
export const authPlugin: PluginOf<Auth> = auth.plugin
|
|
228
|
+
/** auth's entry in the user model's `use`, which `defineUser` adds. */
|
|
229
|
+
export const authUse: UseOf<Auth> = auth.use
|
package/src/rules.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// auth's ready-made rules and default (D-10, D-62, D-79, D-83): each reads
|
|
2
|
+
// the caller, the record's stored keys (D-77) and the value written.
|
|
3
|
+
import type { Caller, CallerRule, Register, UserOf } from 'ultimabase'
|
|
4
|
+
|
|
5
|
+
/** The rule "the caller is signed in" (D-10). */
|
|
6
|
+
export const isAuthenticated: CallerRule = ({ caller }) =>
|
|
7
|
+
caller.kind === 'user'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A rule for signed-in callers only, which receives `user` (D-79); it is
|
|
11
|
+
* `false` for anyone else. Its facts come from where it is used, as `anyOf`'s
|
|
12
|
+
* do; a read rule may still return a filter.
|
|
13
|
+
*/
|
|
14
|
+
export function signedIn<
|
|
15
|
+
Facts extends { readonly caller: Caller },
|
|
16
|
+
R extends boolean | object,
|
|
17
|
+
>(
|
|
18
|
+
rule: (facts: Facts & { readonly user: UserOf<Register> }) => R,
|
|
19
|
+
): (facts: Facts) => R | false {
|
|
20
|
+
return (facts) =>
|
|
21
|
+
facts.caller.kind === 'user'
|
|
22
|
+
? rule({ ...facts, user: facts.caller.user })
|
|
23
|
+
: false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Ref = { readonly id: string } | null
|
|
27
|
+
type Connected = { readonly connect: { readonly id: string } }
|
|
28
|
+
type Keys<V, Shape> = {
|
|
29
|
+
[P in keyof V]-?: NonNullable<V[P]> extends Shape ? P : never
|
|
30
|
+
}[keyof V]
|
|
31
|
+
/** The relations a write may connect, which may also create (D-128). */
|
|
32
|
+
type Connects<V> = {
|
|
33
|
+
[P in keyof V]-?: [Extract<NonNullable<V[P]>, Connected>] extends [never]
|
|
34
|
+
? never
|
|
35
|
+
: P
|
|
36
|
+
}[keyof V]
|
|
37
|
+
/**
|
|
38
|
+
* The record's `one` relations, those a create connects, or any name outside
|
|
39
|
+
* a model's rules.
|
|
40
|
+
*/
|
|
41
|
+
type OwnerKey<Facts> = [Facts] extends [never]
|
|
42
|
+
? string
|
|
43
|
+
: Facts extends { readonly record: infer R }
|
|
44
|
+
? Keys<R, Ref>
|
|
45
|
+
: Facts extends { readonly where: unknown }
|
|
46
|
+
? 'a read rule has no record to own'
|
|
47
|
+
: Facts extends { readonly value: infer V }
|
|
48
|
+
? Connects<V>
|
|
49
|
+
: string
|
|
50
|
+
|
|
51
|
+
/** The facts it needs; a read rule's are refused on the name alone. */
|
|
52
|
+
type Owned<Facts, K extends string> = Facts extends { readonly where: unknown }
|
|
53
|
+
? Facts
|
|
54
|
+
: Facts extends { readonly record: unknown }
|
|
55
|
+
? Facts & { readonly record: { readonly [P in K]: Ref } }
|
|
56
|
+
: Facts extends { readonly value: unknown }
|
|
57
|
+
? Facts
|
|
58
|
+
: Facts & { readonly record: { readonly [P in K]: Ref } }
|
|
59
|
+
|
|
60
|
+
const get = (value: unknown, key: string): unknown =>
|
|
61
|
+
typeof value === 'object' && value !== null
|
|
62
|
+
? Reflect.get(value, key)
|
|
63
|
+
: undefined
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rule "the caller is the user this `one` relation points to, before and
|
|
67
|
+
* after the write" (D-79): the stored key (D-77) when there is one, and the
|
|
68
|
+
* value written, which may only connect the caller. A create that does not
|
|
69
|
+
* write the relation leaves it to its default. Its facts come from where it
|
|
70
|
+
* is used, so a wrong name fails on the name.
|
|
71
|
+
*/
|
|
72
|
+
export function isOwner<
|
|
73
|
+
Facts extends { readonly caller: Caller },
|
|
74
|
+
const K extends OwnerKey<Facts>,
|
|
75
|
+
>(field: K): (facts: Owned<Facts, K>) => boolean {
|
|
76
|
+
return (facts) => {
|
|
77
|
+
const { caller } = facts
|
|
78
|
+
if (caller.kind !== 'user') return false
|
|
79
|
+
const record = get(facts, 'record')
|
|
80
|
+
if (
|
|
81
|
+
record !== undefined &&
|
|
82
|
+
get(get(record, field), 'id') !== caller.user.id
|
|
83
|
+
)
|
|
84
|
+
return false
|
|
85
|
+
const written = get(get(facts, 'value'), field)
|
|
86
|
+
return (
|
|
87
|
+
written === undefined ||
|
|
88
|
+
get(get(written, 'connect'), 'id') === caller.user.id
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The rule "the caller is this record" (D-62). */
|
|
94
|
+
export function isSelf(facts: {
|
|
95
|
+
readonly caller: Caller
|
|
96
|
+
readonly record: { readonly id: string }
|
|
97
|
+
}): boolean {
|
|
98
|
+
return (
|
|
99
|
+
facts.caller.kind === 'user' && facts.caller.user.id === facts.record.id
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The default "whoever creates the record" (D-53, D-62): the signed-in
|
|
105
|
+
* caller's `{ id }`, no value otherwise. Its declared return type spares the
|
|
106
|
+
* user model's own defaults the annotation.
|
|
107
|
+
*/
|
|
108
|
+
export function currentUser(facts: {
|
|
109
|
+
readonly caller: Caller
|
|
110
|
+
}): { readonly id: string } | undefined {
|
|
111
|
+
return facts.caller.kind === 'user' ? { id: facts.caller.user.id } : undefined
|
|
112
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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 type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
6
|
+
import { defineModel, master, PublicError } from 'ultimabase'
|
|
7
|
+
|
|
8
|
+
const M = { as: master }
|
|
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
|
|
15
|
+
|
|
16
|
+
type SessionFields = {
|
|
17
|
+
readonly tokenHash: Field<string, { readonly unique: true }>
|
|
18
|
+
readonly userId: Field<string, ScalarOptions<string>>
|
|
19
|
+
readonly expiresAt: Field<Date, ScalarOptions<Date>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** auth's own model (D-110). */
|
|
23
|
+
export const AuthSession: ModelOf<'AuthSession', SessionFields, []> =
|
|
24
|
+
defineModel({
|
|
25
|
+
name: 'AuthSession',
|
|
26
|
+
fields: (f) => ({
|
|
27
|
+
tokenHash: f.string({ unique: true }),
|
|
28
|
+
userId: f.string(),
|
|
29
|
+
expiresAt: f.dateTime(),
|
|
30
|
+
}),
|
|
31
|
+
indexes: [{ fields: ['userId'] }, { fields: ['expiresAt'] }],
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const digest = (token: string): string =>
|
|
35
|
+
createHash('sha256').update(token).digest('base64url')
|
|
36
|
+
|
|
37
|
+
/** A token shown once, and the session it opens. */
|
|
38
|
+
export interface Opened {
|
|
39
|
+
readonly token: string
|
|
40
|
+
readonly expiresAt: Date
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Opens a session for `userId`, ending every expired one (D-115). */
|
|
44
|
+
export async function openSession(userId: string): Promise<Opened> {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
await AuthSession.deleteMany(
|
|
47
|
+
{ where: { expiresAt: { lte: new Date(now) } } },
|
|
48
|
+
M,
|
|
49
|
+
)
|
|
50
|
+
const token = randomBytes(32).toString('base64url')
|
|
51
|
+
const expiresAt = new Date(now + lifetime)
|
|
52
|
+
await AuthSession.create(
|
|
53
|
+
{ data: { tokenHash: digest(token), userId, expiresAt } },
|
|
54
|
+
M,
|
|
55
|
+
)
|
|
56
|
+
return { token, expiresAt }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
export async function sessionOf(
|
|
65
|
+
token: string,
|
|
66
|
+
): Promise<{ readonly userId: string; readonly expiresAt: Date } | undefined> {
|
|
67
|
+
const tokenHash = digest(token)
|
|
68
|
+
const session = await AuthSession.findUnique({ where: { tokenHash } }, M)
|
|
69
|
+
if (session === null) return undefined
|
|
70
|
+
const now = Date.now()
|
|
71
|
+
const left = session.expiresAt.getTime() - now
|
|
72
|
+
if (left <= 0) {
|
|
73
|
+
await AuthSession.deleteMany({ where: { tokenHash } }, M)
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
const end = Math.min(now + lifetime, session.createdAt.getTime() + longest)
|
|
77
|
+
if (left < renewal && end > session.expiresAt.getTime()) {
|
|
78
|
+
try {
|
|
79
|
+
await AuthSession.update(
|
|
80
|
+
{ where: { tokenHash }, data: { expiresAt: new Date(end) } },
|
|
81
|
+
M,
|
|
82
|
+
)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Signed out between the read and the renewal: the session ended.
|
|
85
|
+
if (error instanceof PublicError && error.code === 'NOT_FOUND') {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
return { userId: session.userId, expiresAt: new Date(end) }
|
|
91
|
+
}
|
|
92
|
+
return session
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Ends the session `token` opened, if any. */
|
|
96
|
+
export async function closeSession(token: string): Promise<void> {
|
|
97
|
+
await AuthSession.deleteMany({ where: { tokenHash: digest(token) } }, M)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Ends every session of a user. */
|
|
101
|
+
export async function revokeSessions(userId: string): Promise<void> {
|
|
102
|
+
await AuthSession.deleteMany({ where: { userId } }, M)
|
|
103
|
+
}
|