@wtfalch/auth 0.4.1 → 0.6.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/dist/auth.d.ts +39 -1
- package/dist/auth.js +127 -20
- package/dist/broker.d.ts +9 -0
- package/dist/broker.js +9 -1
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +4 -38
- package/dist/config.d.ts +12 -1
- package/dist/config.js +16 -0
- package/dist/cookies.d.ts +16 -0
- package/dist/cookies.js +51 -3
- package/dist/index.d.ts +1 -0
- package/dist/namespace-browser.d.ts +4 -0
- package/dist/namespace-browser.js +232 -0
- package/dist/namespace-server.d.ts +12 -0
- package/dist/namespace-server.js +64 -0
- package/dist/namespace-session.d.ts +42 -0
- package/dist/namespace-session.js +253 -0
- package/dist/namespaces.d.ts +118 -0
- package/dist/namespaces.js +244 -0
- package/dist/next.js +14 -2
- package/dist/oidc.d.ts +1 -1
- package/dist/sessions.d.ts +69 -0
- package/dist/sessions.js +119 -0
- package/package.json +14 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side records behind namespace sessions.
|
|
3
|
+
*
|
|
4
|
+
* The browser holds a sealed handle that never changes after sign-in; the
|
|
5
|
+
* tokens live here. That is what makes three things possible that a sealed
|
|
6
|
+
* token cookie cannot do: a logout that also kills copies of the cookie, a
|
|
7
|
+
* refresh that no late response can undo by setting an older cookie, and two
|
|
8
|
+
* replicas refreshing the same session without logging the person out.
|
|
9
|
+
*
|
|
10
|
+
* The store is the app's. It sees a digest of the handle, never the handle,
|
|
11
|
+
* and tokens sealed with the app's cookie secret, never the tokens.
|
|
12
|
+
*/
|
|
13
|
+
export interface SessionRecord {
|
|
14
|
+
/** SHA-256 of the handle, base64url. */
|
|
15
|
+
key: string;
|
|
16
|
+
/** Digest of the namespace binding and revision the session was made under. */
|
|
17
|
+
context: string;
|
|
18
|
+
/** The issuer's user id. */
|
|
19
|
+
subject: string;
|
|
20
|
+
/** Bumped by every write. `advance` only succeeds from the current value. */
|
|
21
|
+
generation: number;
|
|
22
|
+
/**
|
|
23
|
+
* Unix seconds until which one request holds the right to refresh, or null.
|
|
24
|
+
* The issuer rotates refresh tokens, so two replicas refreshing at once
|
|
25
|
+
* would spend the same token and one would be refused. The lease is how
|
|
26
|
+
* exactly one of them asks.
|
|
27
|
+
*/
|
|
28
|
+
leaseUntil: number | null;
|
|
29
|
+
/** The tokens, sealed by the SDK. Opaque here. */
|
|
30
|
+
payload: string;
|
|
31
|
+
/** Unix seconds. Slides forward on every refresh. */
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
/** Unix seconds, or null while the session is live. */
|
|
34
|
+
revokedAt: number | null;
|
|
35
|
+
}
|
|
36
|
+
export type SessionUpdate = Pick<SessionRecord, 'payload' | 'expiresAt' | 'leaseUntil'>;
|
|
37
|
+
export interface SessionStore {
|
|
38
|
+
create(record: SessionRecord): Promise<void>;
|
|
39
|
+
get(key: string): Promise<SessionRecord | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Replace the payload, expiry and lease, and bump the generation, only if the
|
|
42
|
+
* record is still at `generation` and not revoked. Resolves whether it did.
|
|
43
|
+
*/
|
|
44
|
+
advance(key: string, generation: number, next: SessionUpdate): Promise<boolean>;
|
|
45
|
+
revoke(key: string, at: number): Promise<void>;
|
|
46
|
+
/** Revoke every live record for this subject under this binding context. Resolves how many. */
|
|
47
|
+
revokeSubject(context: string, subject: string, at: number): Promise<number>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One process only. For tests and a single-instance development server: two
|
|
51
|
+
* replicas with separate memory stores do not share revocations.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createMemorySessionStore(): SessionStore;
|
|
54
|
+
/** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
|
|
55
|
+
export declare const POSTGRES_SESSION_TABLE = "create table if not exists wtfalch_auth_sessions (\n key text primary key,\n context text not null,\n subject text not null,\n generation integer not null,\n payload text not null,\n expires_at bigint not null,\n lease_until bigint,\n revoked_at bigint\n);\ncreate index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);\ncreate index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);";
|
|
56
|
+
type Row = Record<string, unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* Postgres through whatever driver the app already has. `query` runs one
|
|
59
|
+
* parameterised statement and resolves its rows:
|
|
60
|
+
*
|
|
61
|
+
* - node-postgres: `(text, values) => pool.query(text, values).then((r) => r.rows)`
|
|
62
|
+
* - postgres.js: `(text, values) => sql.unsafe(text, values)`
|
|
63
|
+
*
|
|
64
|
+
* Expired rows are removed a few at a time on each sign-in.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createPostgresSessionStore(input: {
|
|
67
|
+
query: (text: string, values: unknown[]) => Promise<Row[]>;
|
|
68
|
+
}): SessionStore;
|
|
69
|
+
export {};
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One process only. For tests and a single-instance development server: two
|
|
3
|
+
* replicas with separate memory stores do not share revocations.
|
|
4
|
+
*/
|
|
5
|
+
export function createMemorySessionStore() {
|
|
6
|
+
const records = new Map();
|
|
7
|
+
const sweep = (now) => {
|
|
8
|
+
for (const [key, record] of records)
|
|
9
|
+
if (record.expiresAt <= now)
|
|
10
|
+
records.delete(key);
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
async create(record) {
|
|
14
|
+
sweep(Math.floor(Date.now() / 1000));
|
|
15
|
+
if (records.has(record.key))
|
|
16
|
+
throw new Error('session record already exists');
|
|
17
|
+
records.set(record.key, { ...record });
|
|
18
|
+
},
|
|
19
|
+
async get(key) {
|
|
20
|
+
const record = records.get(key);
|
|
21
|
+
return record ? { ...record } : null;
|
|
22
|
+
},
|
|
23
|
+
async advance(key, generation, next) {
|
|
24
|
+
const record = records.get(key);
|
|
25
|
+
if (!record || record.revokedAt !== null || record.generation !== generation)
|
|
26
|
+
return false;
|
|
27
|
+
records.set(key, { ...record, ...next, generation: generation + 1 });
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
async revoke(key, at) {
|
|
31
|
+
const record = records.get(key);
|
|
32
|
+
if (record && record.revokedAt === null)
|
|
33
|
+
record.revokedAt = at;
|
|
34
|
+
},
|
|
35
|
+
async revokeSubject(context, subject, at) {
|
|
36
|
+
let count = 0;
|
|
37
|
+
for (const record of records.values())
|
|
38
|
+
if (record.context === context && record.subject === subject && record.revokedAt === null) {
|
|
39
|
+
record.revokedAt = at;
|
|
40
|
+
count++;
|
|
41
|
+
}
|
|
42
|
+
return count;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The table `createPostgresSessionStore` expects. Run it in the app's own migrations. */
|
|
47
|
+
export const POSTGRES_SESSION_TABLE = `create table if not exists wtfalch_auth_sessions (
|
|
48
|
+
key text primary key,
|
|
49
|
+
context text not null,
|
|
50
|
+
subject text not null,
|
|
51
|
+
generation integer not null,
|
|
52
|
+
payload text not null,
|
|
53
|
+
expires_at bigint not null,
|
|
54
|
+
lease_until bigint,
|
|
55
|
+
revoked_at bigint
|
|
56
|
+
);
|
|
57
|
+
create index if not exists wtfalch_auth_sessions_subject on wtfalch_auth_sessions (context, subject);
|
|
58
|
+
create index if not exists wtfalch_auth_sessions_expiry on wtfalch_auth_sessions (expires_at);`;
|
|
59
|
+
const nullable = (value) => value === null || value === undefined ? null : Number(value);
|
|
60
|
+
/**
|
|
61
|
+
* Postgres through whatever driver the app already has. `query` runs one
|
|
62
|
+
* parameterised statement and resolves its rows:
|
|
63
|
+
*
|
|
64
|
+
* - node-postgres: `(text, values) => pool.query(text, values).then((r) => r.rows)`
|
|
65
|
+
* - postgres.js: `(text, values) => sql.unsafe(text, values)`
|
|
66
|
+
*
|
|
67
|
+
* Expired rows are removed a few at a time on each sign-in.
|
|
68
|
+
*/
|
|
69
|
+
export function createPostgresSessionStore(input) {
|
|
70
|
+
const { query } = input;
|
|
71
|
+
const record = (row) => ({
|
|
72
|
+
key: String(row.key),
|
|
73
|
+
context: String(row.context),
|
|
74
|
+
subject: String(row.subject),
|
|
75
|
+
generation: Number(row.generation),
|
|
76
|
+
payload: String(row.payload),
|
|
77
|
+
expiresAt: Number(row.expires_at),
|
|
78
|
+
leaseUntil: nullable(row.lease_until),
|
|
79
|
+
revokedAt: nullable(row.revoked_at),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
async create(r) {
|
|
83
|
+
await query(`delete from wtfalch_auth_sessions where key in
|
|
84
|
+
(select key from wtfalch_auth_sessions where expires_at <= $1 limit 100)`, [Math.floor(Date.now() / 1000)]);
|
|
85
|
+
await query(`insert into wtfalch_auth_sessions
|
|
86
|
+
(key, context, subject, generation, payload, expires_at, lease_until, revoked_at)
|
|
87
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8)`, [
|
|
88
|
+
r.key,
|
|
89
|
+
r.context,
|
|
90
|
+
r.subject,
|
|
91
|
+
r.generation,
|
|
92
|
+
r.payload,
|
|
93
|
+
r.expiresAt,
|
|
94
|
+
r.leaseUntil,
|
|
95
|
+
r.revokedAt,
|
|
96
|
+
]);
|
|
97
|
+
},
|
|
98
|
+
async get(key) {
|
|
99
|
+
const rows = await query('select * from wtfalch_auth_sessions where key = $1', [key]);
|
|
100
|
+
return rows[0] ? record(rows[0]) : null;
|
|
101
|
+
},
|
|
102
|
+
async advance(key, generation, next) {
|
|
103
|
+
const rows = await query(`update wtfalch_auth_sessions
|
|
104
|
+
set payload = $3, expires_at = $4, lease_until = $5, generation = generation + 1
|
|
105
|
+
where key = $1 and generation = $2 and revoked_at is null
|
|
106
|
+
returning key`, [key, generation, next.payload, next.expiresAt, next.leaseUntil]);
|
|
107
|
+
return rows.length === 1;
|
|
108
|
+
},
|
|
109
|
+
async revoke(key, at) {
|
|
110
|
+
await query('update wtfalch_auth_sessions set revoked_at = $2 where key = $1 and revoked_at is null', [key, at]);
|
|
111
|
+
},
|
|
112
|
+
async revokeSubject(context, subject, at) {
|
|
113
|
+
const rows = await query(`update wtfalch_auth_sessions set revoked_at = $3
|
|
114
|
+
where context = $1 and subject = $2 and revoked_at is null
|
|
115
|
+
returning key`, [context, subject, at]);
|
|
116
|
+
return rows.length;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Sign in against auth.wtfalch.dev: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,6 +21,18 @@
|
|
|
21
21
|
"./browser": {
|
|
22
22
|
"types": "./dist/browser.d.ts",
|
|
23
23
|
"default": "./dist/browser.js"
|
|
24
|
+
},
|
|
25
|
+
"./namespaces": {
|
|
26
|
+
"types": "./dist/namespaces.d.ts",
|
|
27
|
+
"default": "./dist/namespaces.js"
|
|
28
|
+
},
|
|
29
|
+
"./namespace-server": {
|
|
30
|
+
"types": "./dist/namespace-server.d.ts",
|
|
31
|
+
"default": "./dist/namespace-server.js"
|
|
32
|
+
},
|
|
33
|
+
"./sessions": {
|
|
34
|
+
"types": "./dist/sessions.d.ts",
|
|
35
|
+
"default": "./dist/sessions.js"
|
|
24
36
|
}
|
|
25
37
|
},
|
|
26
38
|
"sideEffects": false,
|
|
@@ -46,6 +58,7 @@
|
|
|
46
58
|
}
|
|
47
59
|
},
|
|
48
60
|
"devDependencies": {
|
|
61
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
49
62
|
"@types/node": "^22",
|
|
50
63
|
"@types/react": "^19",
|
|
51
64
|
"@wtfalch/auth-broker": "workspace:*",
|