@substrat-run/vertical-auth 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-schema.d.ts +655 -0
- package/dist/auth-schema.d.ts.map +1 -0
- package/dist/auth-schema.js +73 -0
- package/dist/auth-schema.js.map +1 -0
- package/dist/cookie-domain.d.ts +2 -0
- package/dist/cookie-domain.d.ts.map +1 -0
- package/dist/cookie-domain.js +39 -0
- package/dist/cookie-domain.js.map +1 -0
- package/dist/identity-do.d.ts +161 -0
- package/dist/identity-do.d.ts.map +1 -0
- package/dist/identity-do.js +326 -0
- package/dist/identity-do.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/oidc-rp-provider.d.ts +34 -0
- package/dist/oidc-rp-provider.d.ts.map +1 -0
- package/dist/oidc-rp-provider.js +107 -0
- package/dist/oidc-rp-provider.js.map +1 -0
- package/dist/oidc.d.ts +23 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +77 -0
- package/dist/oidc.js.map +1 -0
- package/dist/provider.d.ts +24 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +2 -0
- package/dist/provider.js.map +1 -0
- package/dist/site-registry.d.ts +39 -0
- package/dist/site-registry.d.ts.map +1 -0
- package/dist/site-registry.js +45 -0
- package/dist/site-registry.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
/**
|
|
4
|
+
* Better Auth's standard tables (user/session/account/verification) as a Drizzle schema,
|
|
5
|
+
* for the per-tenant identity DO's own SQLite. Timestamps are integer epoch-ms; the
|
|
6
|
+
* sub→principal binding is NOT here — it lives in the identity DO's own `identity` table,
|
|
7
|
+
* provider-agnostic (a login authenticated by OIDC has no Better Auth `user` row).
|
|
8
|
+
*/
|
|
9
|
+
export const user = sqliteTable('user', {
|
|
10
|
+
id: text('id').primaryKey(),
|
|
11
|
+
name: text('name').notNull(),
|
|
12
|
+
email: text('email').notNull().unique(),
|
|
13
|
+
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
|
|
14
|
+
image: text('image'),
|
|
15
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
16
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
17
|
+
.notNull(),
|
|
18
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
19
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
20
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
21
|
+
.notNull(),
|
|
22
|
+
});
|
|
23
|
+
export const session = sqliteTable('session', {
|
|
24
|
+
id: text('id').primaryKey(),
|
|
25
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
26
|
+
token: text('token').notNull().unique(),
|
|
27
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
28
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
29
|
+
.notNull(),
|
|
30
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
31
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
32
|
+
.notNull(),
|
|
33
|
+
ipAddress: text('ip_address'),
|
|
34
|
+
userAgent: text('user_agent'),
|
|
35
|
+
userId: text('user_id')
|
|
36
|
+
.notNull()
|
|
37
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
38
|
+
}, (table) => [index('session_userId_idx').on(table.userId)]);
|
|
39
|
+
export const account = sqliteTable('account', {
|
|
40
|
+
id: text('id').primaryKey(),
|
|
41
|
+
accountId: text('account_id').notNull(),
|
|
42
|
+
providerId: text('provider_id').notNull(),
|
|
43
|
+
userId: text('user_id')
|
|
44
|
+
.notNull()
|
|
45
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
46
|
+
accessToken: text('access_token'),
|
|
47
|
+
refreshToken: text('refresh_token'),
|
|
48
|
+
idToken: text('id_token'),
|
|
49
|
+
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
|
50
|
+
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
|
51
|
+
scope: text('scope'),
|
|
52
|
+
password: text('password'),
|
|
53
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
54
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
55
|
+
.notNull(),
|
|
56
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
57
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
58
|
+
.notNull(),
|
|
59
|
+
}, (table) => [index('account_userId_idx').on(table.userId)]);
|
|
60
|
+
export const verification = sqliteTable('verification', {
|
|
61
|
+
id: text('id').primaryKey(),
|
|
62
|
+
identifier: text('identifier').notNull(),
|
|
63
|
+
value: text('value').notNull(),
|
|
64
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
65
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
66
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
67
|
+
.notNull(),
|
|
68
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
69
|
+
.default(sql `(cast(unixepoch('subsecond') * 1000 as integer))`)
|
|
70
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
71
|
+
.notNull(),
|
|
72
|
+
}, (table) => [index('verification_identifier_idx').on(table.identifier)]);
|
|
73
|
+
//# sourceMappingURL=auth-schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-schema.js","sourceRoot":"","sources":["../src/auth-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie-domain.d.ts","sourceRoot":"","sources":["../src/cookie-domain.ts"],"names":[],"mappings":"AAuBA,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAW/F"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared-session cookie domain (vertical-auth-detach.md; the multi-surface case,
|
|
3
|
+
* K-26): a scope whose surfaces are sibling hostnames — `crm.egeryds.se`,
|
|
4
|
+
* `eka.egeryds.se`, … — shares ONE login by setting the session cookie with
|
|
5
|
+
* `Domain=egeryds.se`. The signing secret is already per-tenant (DO-minted), so the
|
|
6
|
+
* cookie verifies on every surface; the Domain attribute is the only missing piece.
|
|
7
|
+
*
|
|
8
|
+
* The domain is per-scope DELIVERED config (`substrat:auth`), never code, and it is
|
|
9
|
+
* validated HERE, where the cookie is set: it must cover the request host (equal, or a
|
|
10
|
+
* proper suffix at a label boundary) and must not be a bare TLD. A domain that fails
|
|
11
|
+
* validation degrades to a host-only cookie — sessions simply don't share — because a
|
|
12
|
+
* misdelivered config must never break sign-in.
|
|
13
|
+
*
|
|
14
|
+
* The registrable-suffix half is now enforced HERE too, not only upstream (#305, D-35):
|
|
15
|
+
* a configured domain that is itself a public suffix — `co.uk`, `pages.dev`, or any
|
|
16
|
+
* multi-level registry suffix a label-count check would miss — is rejected via the
|
|
17
|
+
* vendored Public Suffix List (`@substrat-run/psl`). A cookie on a public suffix spans
|
|
18
|
+
* every tenant registered under it, so it must never be honoured. The platform's own
|
|
19
|
+
* apex (`substrat.run`) is an ordinary registrable domain, NOT a public suffix, so it is
|
|
20
|
+
* additionally guarded upstream (the platform refuses to deliver it as a cookie-domain).
|
|
21
|
+
*/
|
|
22
|
+
import { isPublicSuffix } from '@substrat-run/psl';
|
|
23
|
+
export function resolveCookieDomain(configured, host) {
|
|
24
|
+
if (!configured)
|
|
25
|
+
return null;
|
|
26
|
+
const domain = configured.trim().toLowerCase().replace(/^\./, '');
|
|
27
|
+
if (!domain.includes('.'))
|
|
28
|
+
return null; // a bare TLD is never a session boundary
|
|
29
|
+
const h = host.toLowerCase();
|
|
30
|
+
if (h !== domain && !h.endsWith(`.${domain}`))
|
|
31
|
+
return null; // browser would reject it anyway
|
|
32
|
+
// Registrable-suffix guard (D-35): a cookie whose Domain is a public suffix — `co.uk`,
|
|
33
|
+
// `pages.dev`, any multi-level registry suffix a label-count check misses — spans every
|
|
34
|
+
// tenant under it. Reject it; the session degrades to host-only rather than leaking.
|
|
35
|
+
if (isPublicSuffix(domain))
|
|
36
|
+
return null;
|
|
37
|
+
return domain;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=cookie-domain.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie-domain.js","sourceRoot":"","sources":["../src/cookie-domain.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnD,MAAM,UAAU,mBAAmB,CAAC,UAA8B,EAAE,IAAY;IAC9E,IAAI,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IAC7B,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAClE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,yCAAyC;IACjF,MAAM,CAAC,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAC7B,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QAAE,OAAO,IAAI,CAAC,CAAC,iCAAiC;IAC7F,uFAAuF;IACvF,wFAAwF;IACxF,qFAAqF;IACrF,IAAI,cAAc,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAC;IACxC,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import type { AuthProvider } from './provider.js';
|
|
3
|
+
import { type SiteRow } from './site-registry.js';
|
|
4
|
+
export type IdentityDoEnv = Record<string, never>;
|
|
5
|
+
export declare class IdentityDO extends DurableObject<IdentityDoEnv> {
|
|
6
|
+
/** This tenant's Better Auth signing secret — generated in this DO, never shared, never a worker binding. */
|
|
7
|
+
private authSecret;
|
|
8
|
+
constructor(ctx: DurableObjectState, env: IdentityDoEnv);
|
|
9
|
+
/**
|
|
10
|
+
* Upsert per-scope config delivered by the platform (vertical-auth-detach.md §2.2).
|
|
11
|
+
* Key-by-key, never a replace, so partial deliveries compose; idempotent so the
|
|
12
|
+
* reconciliation sweep can re-run it.
|
|
13
|
+
*/
|
|
14
|
+
setScopeConfig(scopeId: string, entries: Array<{
|
|
15
|
+
key: string;
|
|
16
|
+
value: string;
|
|
17
|
+
}>): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* The config DELIVERED to one scope (dashboard Env tab → `/internal/configure` →
|
|
20
|
+
* `setScopeConfig`), as a plain map — the `delivered` argument of the contracts helper
|
|
21
|
+
* `resolveScopedEnvSpec(spec, env, delivered)`. This is the blessed read for a
|
|
22
|
+
* vertical's ORDINARY env-spec keys (issue #398): overlay these values on worker env,
|
|
23
|
+
* per-scope wins. `authWiring` below is the auth-specific composite (config + session
|
|
24
|
+
* secret in one hop); use that when you also need the secret, this when you don't.
|
|
25
|
+
*/
|
|
26
|
+
getScopeConfig(scopeId: string): Promise<Record<string, string>>;
|
|
27
|
+
/**
|
|
28
|
+
* Everything the worker needs to build the scope's `AuthProvider`, in ONE round-trip:
|
|
29
|
+
* the scope's delivered config (notably `substrat:auth`) plus this tenant's
|
|
30
|
+
* session-signing secret (minted here on first use, never a worker binding — the same
|
|
31
|
+
* per-tenant-secret move as `auth_secret`). One method rather than two keeps the
|
|
32
|
+
* per-request cost at a single DO hop.
|
|
33
|
+
*/
|
|
34
|
+
authWiring(scopeId: string): Promise<{
|
|
35
|
+
config: Record<string, string>;
|
|
36
|
+
sessionSecret: string;
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Record a SITE (scope) at provision — the vertical's own memory of its sites (K-26
|
|
40
|
+
* multi-scope), used to enumerate and switch between them without reaching the control-plane
|
|
41
|
+
* directory. Idempotent: a re-provision (K-31) updates the slug/name but keeps `created_at`,
|
|
42
|
+
* so the switcher's ordering is stable.
|
|
43
|
+
*/
|
|
44
|
+
recordSite(scopeId: string, slug: string, name: string): Promise<void>;
|
|
45
|
+
/** Drop a site from the registry (the vertical's half of archiving it). Idempotent. */
|
|
46
|
+
forgetSite(scopeId: string): Promise<void>;
|
|
47
|
+
/** The tenant's sites, oldest first — the in-app site switcher's list. */
|
|
48
|
+
listSites(): Promise<SiteRow[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a site SLUG to its scope id within this tenant — how the worker turns the app's
|
|
51
|
+
* `x-site` selection into the scope to open. Null ⇒ no such site (the caller falls back to the
|
|
52
|
+
* routed home scope). Tenant-scoped by construction: this DO is per-tenant, so a slug can only
|
|
53
|
+
* ever resolve to a scope of THIS tenant.
|
|
54
|
+
*/
|
|
55
|
+
resolveSiteScope(slug: string): Promise<string | null>;
|
|
56
|
+
/** The DO's SQLite as the registry's minimal `exec` seam (see site-registry.ts). */
|
|
57
|
+
private get registrySql();
|
|
58
|
+
/**
|
|
59
|
+
* Record the owner seat at provision. Writes BOTH the transient `pending_owner` (claimed +
|
|
60
|
+
* consumed by the first login) and the durable `owner_of_record` (#332, never consumed) in one
|
|
61
|
+
* call, so the vertical always retains who the owner is even after the seat has been claimed.
|
|
62
|
+
* Idempotent — the reconciliation sweep re-runs it.
|
|
63
|
+
*/
|
|
64
|
+
setPendingOwner(scopeId: string, principal: string): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* The scope's durable owner of record, or null if this scope was never provisioned through this
|
|
67
|
+
* DO (a legacy scope predating #332, or a slug typo). Read by the reconcile path to re-grant the
|
|
68
|
+
* owner their role after a scope-DO wipe left the scope enforcing against an empty tuple table.
|
|
69
|
+
*/
|
|
70
|
+
getOwnerOfRecord(scopeId: string): Promise<string | null>;
|
|
71
|
+
/**
|
|
72
|
+
* Is this scope awaiting first-run setup? True while its owner seat is unclaimed — the
|
|
73
|
+
* instance is provisioned but no admin has signed in yet. The worker uses this to show a
|
|
74
|
+
* "create the admin account" setup screen instead of a plain sign-in, and to keep open
|
|
75
|
+
* sign-up allowed only during this window (after the owner claims, sign-up is invite-only).
|
|
76
|
+
*/
|
|
77
|
+
needsSetup(scopeId: string): Promise<boolean>;
|
|
78
|
+
/**
|
|
79
|
+
* Map a verified subject to a PrincipalId in this scope. If already bound, return it. If
|
|
80
|
+
* not, and the scope's owner seat is unclaimed, CLAIM it: bind this subject to the owner
|
|
81
|
+
* principal and consume the pending seat. Otherwise null — a valid login with no seat has
|
|
82
|
+
* no access. Provider-agnostic: the subject may come from Better Auth or an OIDC issuer.
|
|
83
|
+
*/
|
|
84
|
+
resolvePrincipal(scopeId: string, sub: string): Promise<string | null>;
|
|
85
|
+
/**
|
|
86
|
+
* Record a member invite: a pre-minted `principal` + `roleKey` the caller has already
|
|
87
|
+
* granted at scope level, claimable by whoever presents the token whose hash is `tokenHash`.
|
|
88
|
+
* The plaintext token never reaches the DO — only its hash, so a DB read can't mint access.
|
|
89
|
+
*/
|
|
90
|
+
createInvite(scopeId: string, principal: string, roleKey: string, email: string | null, tokenHash: string): Promise<void>;
|
|
91
|
+
/** The scope's outstanding (unclaimed) invites — for the admin's pending-invites list. No token. */
|
|
92
|
+
listInvites(scopeId: string): Promise<Array<{
|
|
93
|
+
principal: string;
|
|
94
|
+
roleKey: string;
|
|
95
|
+
email: string | null;
|
|
96
|
+
createdAt: number;
|
|
97
|
+
}>>;
|
|
98
|
+
/** Is there an unclaimed invite for this token hash? (the sign-up gate consults this post-setup). */
|
|
99
|
+
inviteExists(scopeId: string, tokenHash: string): Promise<boolean>;
|
|
100
|
+
/** Withdraw an unclaimed invite by its (pre-minted) principal — the id the admin sees. */
|
|
101
|
+
revokeInvite(scopeId: string, principal: string): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Claim an invite: bind this verified subject to the invite's pre-minted principal and
|
|
104
|
+
* consume the invite. Returns the principal, or null if no unclaimed invite matches the
|
|
105
|
+
* token hash. The role was granted at scope level when the invite was created, so the
|
|
106
|
+
* bound principal resolves its permissions immediately.
|
|
107
|
+
*/
|
|
108
|
+
claimInvite(scopeId: string, sub: string, tokenHash: string): Promise<string | null>;
|
|
109
|
+
/** A Better Auth instance over THIS DO's SQLite, trusting the caller's origin. */
|
|
110
|
+
private auth;
|
|
111
|
+
/**
|
|
112
|
+
* The DO's HTTP surface (used only when Better Auth is the chosen provider). `/__session`
|
|
113
|
+
* resolves the request to an `AuthSubject`; everything else is a Better Auth request. The
|
|
114
|
+
* worker forwards requests here; Better Auth never runs in the worker.
|
|
115
|
+
*
|
|
116
|
+
* `x-substrat-cookie-domain` is the worker relaying the scope's delivered `substrat:auth`
|
|
117
|
+
* cookie-domain choice (the DO can't look it up itself — this fetch carries no scope id).
|
|
118
|
+
* The stub is reachable only from the worker, so the header is as trusted as the config;
|
|
119
|
+
* it is still re-validated against the request host before it touches a Set-Cookie.
|
|
120
|
+
*/
|
|
121
|
+
fetch(request: Request): Promise<Response>;
|
|
122
|
+
}
|
|
123
|
+
/** A minimal stub shape — the identity DO's callable surface (avoids leaking the full class type). */
|
|
124
|
+
export type IdentityStub = {
|
|
125
|
+
fetch(request: Request): Promise<Response>;
|
|
126
|
+
setScopeConfig(scopeId: string, entries: Array<{
|
|
127
|
+
key: string;
|
|
128
|
+
value: string;
|
|
129
|
+
}>): Promise<void>;
|
|
130
|
+
getScopeConfig(scopeId: string): Promise<Record<string, string>>;
|
|
131
|
+
authWiring(scopeId: string): Promise<{
|
|
132
|
+
config: Record<string, string>;
|
|
133
|
+
sessionSecret: string;
|
|
134
|
+
}>;
|
|
135
|
+
setPendingOwner(scopeId: string, principal: string): Promise<void>;
|
|
136
|
+
getOwnerOfRecord(scopeId: string): Promise<string | null>;
|
|
137
|
+
needsSetup(scopeId: string): Promise<boolean>;
|
|
138
|
+
resolvePrincipal(scopeId: string, sub: string): Promise<string | null>;
|
|
139
|
+
createInvite(scopeId: string, principal: string, roleKey: string, email: string | null, tokenHash: string): Promise<void>;
|
|
140
|
+
listInvites(scopeId: string): Promise<Array<{
|
|
141
|
+
principal: string;
|
|
142
|
+
roleKey: string;
|
|
143
|
+
email: string | null;
|
|
144
|
+
createdAt: number;
|
|
145
|
+
}>>;
|
|
146
|
+
inviteExists(scopeId: string, tokenHash: string): Promise<boolean>;
|
|
147
|
+
revokeInvite(scopeId: string, principal: string): Promise<void>;
|
|
148
|
+
claimInvite(scopeId: string, sub: string, tokenHash: string): Promise<string | null>;
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* The `AuthProvider` backed by a tenant's identity-DO stub (the `better-auth-do` config).
|
|
152
|
+
* `handle` forwards the raw request; `resolve` asks the DO's `/__session` probe, carrying
|
|
153
|
+
* the request's cookies. The worker holds only this — never Better Auth itself.
|
|
154
|
+
*
|
|
155
|
+
* `cookieDomain` (the scope's delivered `substrat:auth` choice) rides to the DO as a
|
|
156
|
+
* header because the worker holds the config and the DO holds Better Auth — see fetch().
|
|
157
|
+
*/
|
|
158
|
+
export declare function doAuthProvider(stub: Pick<IdentityStub, 'fetch'>, origin: string, opts?: {
|
|
159
|
+
cookieDomain?: string;
|
|
160
|
+
}): AuthProvider;
|
|
161
|
+
//# sourceMappingURL=identity-do.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-do.d.ts","sourceRoot":"","sources":["../src/identity-do.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { betterAuth } from 'better-auth';
|
|
3
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
4
|
+
import { drizzle } from 'drizzle-orm/durable-sqlite';
|
|
5
|
+
import * as schema from './auth-schema.js';
|
|
6
|
+
import { resolveCookieDomain } from './cookie-domain.js';
|
|
7
|
+
import { SITE_REGISTRY_DDL, recordSite as recordSiteRow, listSites as listSiteRows, resolveSiteScope as resolveSiteScopeRow, forgetSite as forgetSiteRow, } from './site-registry.js';
|
|
8
|
+
/**
|
|
9
|
+
* The per-tenant IDENTITY Durable Object — one per tenant, running its OWN Better Auth
|
|
10
|
+
* over its OWN SQLite (Drizzle's Durable-Object driver, not a shared D1), and holding the
|
|
11
|
+
* provider-agnostic `sub → principal` directory. A tenant's users/sessions/credentials are
|
|
12
|
+
* isolated in its DO; there is NO per-worker `AUTH_DB` to leak across tenants.
|
|
13
|
+
*
|
|
14
|
+
* It runs in its own isolate (separate process + storage from the vertical's business-data
|
|
15
|
+
* DO), and is one of the vertical's OWN DO classes — a legal sandbox-clean binding (the
|
|
16
|
+
* contract refuses only cross-script / platform bindings).
|
|
17
|
+
*
|
|
18
|
+
* Two roles, either or both used per deployment:
|
|
19
|
+
* - `doAuthProvider` (below) exposes Better Auth here as an `AuthProvider` (the
|
|
20
|
+
* `better-auth-do` config). With an OIDC provider instead, Better Auth stays dormant.
|
|
21
|
+
* - `setPendingOwner` / `resolvePrincipal` are the identity directory — used under EVERY
|
|
22
|
+
* provider, since the subject → principal mapping is provider-independent.
|
|
23
|
+
*/
|
|
24
|
+
const SCHEMA_STATEMENTS = [
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS user (
|
|
26
|
+
id TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE,
|
|
27
|
+
email_verified INTEGER NOT NULL DEFAULT 0, image TEXT,
|
|
28
|
+
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
|
29
|
+
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS session (
|
|
31
|
+
id TEXT PRIMARY KEY NOT NULL, expires_at INTEGER NOT NULL, token TEXT NOT NULL UNIQUE,
|
|
32
|
+
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
|
33
|
+
updated_at INTEGER NOT NULL, ip_address TEXT, user_agent TEXT,
|
|
34
|
+
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE)`,
|
|
35
|
+
`CREATE INDEX IF NOT EXISTS session_userId_idx ON session (user_id)`,
|
|
36
|
+
`CREATE TABLE IF NOT EXISTS account (
|
|
37
|
+
id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL, provider_id TEXT NOT NULL,
|
|
38
|
+
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
|
39
|
+
access_token TEXT, refresh_token TEXT, id_token TEXT,
|
|
40
|
+
access_token_expires_at INTEGER, refresh_token_expires_at INTEGER, scope TEXT, password TEXT,
|
|
41
|
+
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
|
42
|
+
updated_at INTEGER NOT NULL)`,
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS account_userId_idx ON account (user_id)`,
|
|
44
|
+
`CREATE TABLE IF NOT EXISTS verification (
|
|
45
|
+
id TEXT PRIMARY KEY NOT NULL, identifier TEXT NOT NULL, value TEXT NOT NULL, expires_at INTEGER NOT NULL,
|
|
46
|
+
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
|
47
|
+
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
|
|
48
|
+
`CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification (identifier)`,
|
|
49
|
+
// The PROVIDER-AGNOSTIC identity directory: a verified subject (`sub`) → the Substrat
|
|
50
|
+
// PrincipalId it maps to, per scope (K-22 — the same login is a different principal in
|
|
51
|
+
// each scope). Independent of WHICH provider verified the subject.
|
|
52
|
+
`CREATE TABLE IF NOT EXISTS identity (scope_id TEXT NOT NULL, sub TEXT NOT NULL, principal TEXT NOT NULL, PRIMARY KEY (scope_id, sub))`,
|
|
53
|
+
// The owner seat waiting to be claimed: set at provision, consumed by the first login.
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS pending_owner (scope_id TEXT PRIMARY KEY, principal TEXT NOT NULL)`,
|
|
55
|
+
// The DURABLE owner of record: also set at provision, but NEVER consumed (#332). `pending_owner`
|
|
56
|
+
// is gone the moment the owner first signs in, so it can't answer "who owns this scope" after
|
|
57
|
+
// that. This can — it's the vertical's own memory of the owner, in the per-tenant IdentityDO
|
|
58
|
+
// (a different DO from the scope's data DO), so it survives a scope-DO storage wipe. A builder-
|
|
59
|
+
// triggered reconcile reads it to re-establish the owner's role grant without the platform
|
|
60
|
+
// secret and without the CP having to persist an owner it doesn't otherwise track.
|
|
61
|
+
`CREATE TABLE IF NOT EXISTS owner_of_record (scope_id TEXT PRIMARY KEY, principal TEXT NOT NULL)`,
|
|
62
|
+
// Outstanding member invites (the post-setup join path). Each is a pre-minted principal +
|
|
63
|
+
// role the admin already granted at scope level, waiting for a login to claim it by token.
|
|
64
|
+
// Only the token's HASH is stored — the token itself lives in the accept link, never here.
|
|
65
|
+
`CREATE TABLE IF NOT EXISTS invite (
|
|
66
|
+
token_hash TEXT PRIMARY KEY, scope_id TEXT NOT NULL, principal TEXT NOT NULL,
|
|
67
|
+
role_key TEXT NOT NULL, email TEXT, claimed INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)))`,
|
|
69
|
+
`CREATE INDEX IF NOT EXISTS invite_by_scope ON invite (scope_id)`,
|
|
70
|
+
// This DO's own config — notably its session-signing secret, generated here per tenant.
|
|
71
|
+
`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
|
72
|
+
// PER-SCOPE instance config, delivered by the platform via /internal/configure
|
|
73
|
+
// (vertical-auth-detach.md §2.2) — notably the scope's `substrat:auth` issuer choice.
|
|
74
|
+
// Lives here because this DO is the vertical's per-tenant harness store and the scope's
|
|
75
|
+
// own data DO exposes no harness-writable surface.
|
|
76
|
+
`CREATE TABLE IF NOT EXISTS scope_config (scope_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (scope_id, key))`,
|
|
77
|
+
// The tenant's SITES (K-26 multi-scope) — each site IS a scope. Recorded here at provision so
|
|
78
|
+
// the vertical can enumerate its own sites CP-lessly: the control-plane directory is the
|
|
79
|
+
// platform's list, this is the vertical's own, in the per-tenant IdentityDO (survives a
|
|
80
|
+
// scope-DO wipe). Powers the in-app switcher + the worker's slug → scope resolution. The
|
|
81
|
+
// table/queries live in `site-registry.ts` so they are unit-testable without a DO.
|
|
82
|
+
...SITE_REGISTRY_DDL,
|
|
83
|
+
];
|
|
84
|
+
export class IdentityDO extends DurableObject {
|
|
85
|
+
/** This tenant's Better Auth signing secret — generated in this DO, never shared, never a worker binding. */
|
|
86
|
+
authSecret;
|
|
87
|
+
constructor(ctx, env) {
|
|
88
|
+
super(ctx, env);
|
|
89
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
90
|
+
for (const stmt of SCHEMA_STATEMENTS)
|
|
91
|
+
ctx.storage.sql.exec(stmt);
|
|
92
|
+
// Load-or-generate this tenant's OWN signing secret. Each IdentityDO mints its own on
|
|
93
|
+
// first init and persists it here, so the secret is per-tenant, never leaves the DO,
|
|
94
|
+
// and needs no `wrangler secret put` (which would be one value shared across every
|
|
95
|
+
// tenant on the deployed script — the multi-tenant hazard we're avoiding).
|
|
96
|
+
const row = [...ctx.storage.sql.exec("SELECT value FROM config WHERE key = 'auth_secret'")][0];
|
|
97
|
+
if (row) {
|
|
98
|
+
this.authSecret = row.value;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
102
|
+
this.authSecret = btoa(String.fromCharCode(...bytes));
|
|
103
|
+
ctx.storage.sql.exec("INSERT INTO config (key, value) VALUES ('auth_secret', ?)", this.authSecret);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Upsert per-scope config delivered by the platform (vertical-auth-detach.md §2.2).
|
|
109
|
+
* Key-by-key, never a replace, so partial deliveries compose; idempotent so the
|
|
110
|
+
* reconciliation sweep can re-run it.
|
|
111
|
+
*/
|
|
112
|
+
async setScopeConfig(scopeId, entries) {
|
|
113
|
+
for (const { key, value } of entries) {
|
|
114
|
+
this.ctx.storage.sql.exec('INSERT OR REPLACE INTO scope_config (scope_id, key, value) VALUES (?, ?, ?)', scopeId, key, value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The config DELIVERED to one scope (dashboard Env tab → `/internal/configure` →
|
|
119
|
+
* `setScopeConfig`), as a plain map — the `delivered` argument of the contracts helper
|
|
120
|
+
* `resolveScopedEnvSpec(spec, env, delivered)`. This is the blessed read for a
|
|
121
|
+
* vertical's ORDINARY env-spec keys (issue #398): overlay these values on worker env,
|
|
122
|
+
* per-scope wins. `authWiring` below is the auth-specific composite (config + session
|
|
123
|
+
* secret in one hop); use that when you also need the secret, this when you don't.
|
|
124
|
+
*/
|
|
125
|
+
async getScopeConfig(scopeId) {
|
|
126
|
+
const config = {};
|
|
127
|
+
for (const row of this.ctx.storage.sql.exec('SELECT key, value FROM scope_config WHERE scope_id = ?', scopeId)) {
|
|
128
|
+
config[row.key] = row.value;
|
|
129
|
+
}
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Everything the worker needs to build the scope's `AuthProvider`, in ONE round-trip:
|
|
134
|
+
* the scope's delivered config (notably `substrat:auth`) plus this tenant's
|
|
135
|
+
* session-signing secret (minted here on first use, never a worker binding — the same
|
|
136
|
+
* per-tenant-secret move as `auth_secret`). One method rather than two keeps the
|
|
137
|
+
* per-request cost at a single DO hop.
|
|
138
|
+
*/
|
|
139
|
+
async authWiring(scopeId) {
|
|
140
|
+
const config = await this.getScopeConfig(scopeId);
|
|
141
|
+
let secret = [...this.ctx.storage.sql.exec("SELECT value FROM config WHERE key = 'session_secret'")][0]?.value;
|
|
142
|
+
if (!secret) {
|
|
143
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
144
|
+
secret = btoa(String.fromCharCode(...bytes));
|
|
145
|
+
this.ctx.storage.sql.exec("INSERT INTO config (key, value) VALUES ('session_secret', ?)", secret);
|
|
146
|
+
}
|
|
147
|
+
return { config, sessionSecret: secret };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Record a SITE (scope) at provision — the vertical's own memory of its sites (K-26
|
|
151
|
+
* multi-scope), used to enumerate and switch between them without reaching the control-plane
|
|
152
|
+
* directory. Idempotent: a re-provision (K-31) updates the slug/name but keeps `created_at`,
|
|
153
|
+
* so the switcher's ordering is stable.
|
|
154
|
+
*/
|
|
155
|
+
async recordSite(scopeId, slug, name) {
|
|
156
|
+
recordSiteRow(this.registrySql, scopeId, slug, name);
|
|
157
|
+
}
|
|
158
|
+
/** Drop a site from the registry (the vertical's half of archiving it). Idempotent. */
|
|
159
|
+
async forgetSite(scopeId) {
|
|
160
|
+
forgetSiteRow(this.registrySql, scopeId);
|
|
161
|
+
}
|
|
162
|
+
/** The tenant's sites, oldest first — the in-app site switcher's list. */
|
|
163
|
+
async listSites() {
|
|
164
|
+
return listSiteRows(this.registrySql);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a site SLUG to its scope id within this tenant — how the worker turns the app's
|
|
168
|
+
* `x-site` selection into the scope to open. Null ⇒ no such site (the caller falls back to the
|
|
169
|
+
* routed home scope). Tenant-scoped by construction: this DO is per-tenant, so a slug can only
|
|
170
|
+
* ever resolve to a scope of THIS tenant.
|
|
171
|
+
*/
|
|
172
|
+
async resolveSiteScope(slug) {
|
|
173
|
+
return resolveSiteScopeRow(this.registrySql, slug);
|
|
174
|
+
}
|
|
175
|
+
/** The DO's SQLite as the registry's minimal `exec` seam (see site-registry.ts). */
|
|
176
|
+
get registrySql() {
|
|
177
|
+
return this.ctx.storage.sql;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Record the owner seat at provision. Writes BOTH the transient `pending_owner` (claimed +
|
|
181
|
+
* consumed by the first login) and the durable `owner_of_record` (#332, never consumed) in one
|
|
182
|
+
* call, so the vertical always retains who the owner is even after the seat has been claimed.
|
|
183
|
+
* Idempotent — the reconciliation sweep re-runs it.
|
|
184
|
+
*/
|
|
185
|
+
async setPendingOwner(scopeId, principal) {
|
|
186
|
+
this.ctx.storage.sql.exec('INSERT OR REPLACE INTO pending_owner (scope_id, principal) VALUES (?, ?)', scopeId, principal);
|
|
187
|
+
this.ctx.storage.sql.exec('INSERT OR REPLACE INTO owner_of_record (scope_id, principal) VALUES (?, ?)', scopeId, principal);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The scope's durable owner of record, or null if this scope was never provisioned through this
|
|
191
|
+
* DO (a legacy scope predating #332, or a slug typo). Read by the reconcile path to re-grant the
|
|
192
|
+
* owner their role after a scope-DO wipe left the scope enforcing against an empty tuple table.
|
|
193
|
+
*/
|
|
194
|
+
async getOwnerOfRecord(scopeId) {
|
|
195
|
+
const row = [...this.ctx.storage.sql.exec('SELECT principal FROM owner_of_record WHERE scope_id = ?', scopeId)][0];
|
|
196
|
+
return row?.principal ?? null;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Is this scope awaiting first-run setup? True while its owner seat is unclaimed — the
|
|
200
|
+
* instance is provisioned but no admin has signed in yet. The worker uses this to show a
|
|
201
|
+
* "create the admin account" setup screen instead of a plain sign-in, and to keep open
|
|
202
|
+
* sign-up allowed only during this window (after the owner claims, sign-up is invite-only).
|
|
203
|
+
*/
|
|
204
|
+
async needsSetup(scopeId) {
|
|
205
|
+
const pending = [...this.ctx.storage.sql.exec('SELECT 1 FROM pending_owner WHERE scope_id = ?', scopeId)][0];
|
|
206
|
+
return pending !== undefined;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Map a verified subject to a PrincipalId in this scope. If already bound, return it. If
|
|
210
|
+
* not, and the scope's owner seat is unclaimed, CLAIM it: bind this subject to the owner
|
|
211
|
+
* principal and consume the pending seat. Otherwise null — a valid login with no seat has
|
|
212
|
+
* no access. Provider-agnostic: the subject may come from Better Auth or an OIDC issuer.
|
|
213
|
+
*/
|
|
214
|
+
async resolvePrincipal(scopeId, sub) {
|
|
215
|
+
const bound = [...this.ctx.storage.sql.exec('SELECT principal FROM identity WHERE scope_id = ? AND sub = ?', scopeId, sub)][0];
|
|
216
|
+
if (bound)
|
|
217
|
+
return bound.principal;
|
|
218
|
+
const pending = [...this.ctx.storage.sql.exec('SELECT principal FROM pending_owner WHERE scope_id = ?', scopeId)][0];
|
|
219
|
+
if (!pending)
|
|
220
|
+
return null;
|
|
221
|
+
this.ctx.storage.sql.exec('INSERT OR REPLACE INTO identity (scope_id, sub, principal) VALUES (?, ?, ?)', scopeId, sub, pending.principal);
|
|
222
|
+
this.ctx.storage.sql.exec('DELETE FROM pending_owner WHERE scope_id = ?', scopeId);
|
|
223
|
+
return pending.principal;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Record a member invite: a pre-minted `principal` + `roleKey` the caller has already
|
|
227
|
+
* granted at scope level, claimable by whoever presents the token whose hash is `tokenHash`.
|
|
228
|
+
* The plaintext token never reaches the DO — only its hash, so a DB read can't mint access.
|
|
229
|
+
*/
|
|
230
|
+
async createInvite(scopeId, principal, roleKey, email, tokenHash) {
|
|
231
|
+
this.ctx.storage.sql.exec('INSERT INTO invite (token_hash, scope_id, principal, role_key, email) VALUES (?, ?, ?, ?, ?)', tokenHash, scopeId, principal, roleKey, email);
|
|
232
|
+
}
|
|
233
|
+
/** The scope's outstanding (unclaimed) invites — for the admin's pending-invites list. No token. */
|
|
234
|
+
async listInvites(scopeId) {
|
|
235
|
+
return [...this.ctx.storage.sql.exec('SELECT principal, role_key, email, created_at FROM invite WHERE scope_id = ? AND claimed = 0 ORDER BY created_at DESC', scopeId)].map((r) => ({ principal: r.principal, roleKey: r.role_key, email: r.email ?? null, createdAt: r.created_at }));
|
|
236
|
+
}
|
|
237
|
+
/** Is there an unclaimed invite for this token hash? (the sign-up gate consults this post-setup). */
|
|
238
|
+
async inviteExists(scopeId, tokenHash) {
|
|
239
|
+
const r = [...this.ctx.storage.sql.exec('SELECT 1 FROM invite WHERE scope_id = ? AND token_hash = ? AND claimed = 0', scopeId, tokenHash)][0];
|
|
240
|
+
return r !== undefined;
|
|
241
|
+
}
|
|
242
|
+
/** Withdraw an unclaimed invite by its (pre-minted) principal — the id the admin sees. */
|
|
243
|
+
async revokeInvite(scopeId, principal) {
|
|
244
|
+
this.ctx.storage.sql.exec('DELETE FROM invite WHERE scope_id = ? AND principal = ? AND claimed = 0', scopeId, principal);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Claim an invite: bind this verified subject to the invite's pre-minted principal and
|
|
248
|
+
* consume the invite. Returns the principal, or null if no unclaimed invite matches the
|
|
249
|
+
* token hash. The role was granted at scope level when the invite was created, so the
|
|
250
|
+
* bound principal resolves its permissions immediately.
|
|
251
|
+
*/
|
|
252
|
+
async claimInvite(scopeId, sub, tokenHash) {
|
|
253
|
+
const inv = [...this.ctx.storage.sql.exec('SELECT principal FROM invite WHERE scope_id = ? AND token_hash = ? AND claimed = 0', scopeId, tokenHash)][0];
|
|
254
|
+
if (!inv)
|
|
255
|
+
return null;
|
|
256
|
+
this.ctx.storage.sql.exec('INSERT OR REPLACE INTO identity (scope_id, sub, principal) VALUES (?, ?, ?)', scopeId, sub, inv.principal);
|
|
257
|
+
this.ctx.storage.sql.exec('UPDATE invite SET claimed = 1 WHERE scope_id = ? AND token_hash = ?', scopeId, tokenHash);
|
|
258
|
+
return inv.principal;
|
|
259
|
+
}
|
|
260
|
+
/** A Better Auth instance over THIS DO's SQLite, trusting the caller's origin. */
|
|
261
|
+
auth(origin, cookieDomain) {
|
|
262
|
+
const db = drizzle(this.ctx.storage, { schema });
|
|
263
|
+
return betterAuth({
|
|
264
|
+
database: drizzleAdapter(db, { provider: 'sqlite', schema }),
|
|
265
|
+
emailAndPassword: { enabled: true, autoSignIn: true, minPasswordLength: 8 },
|
|
266
|
+
// Per-tenant, generated + persisted in THIS DO (see the constructor) — never shared.
|
|
267
|
+
secret: this.authSecret,
|
|
268
|
+
baseURL: origin,
|
|
269
|
+
trustedOrigins: [origin],
|
|
270
|
+
// Shared login across a scope's surfaces (crm.…, eka.… — one parent domain): the
|
|
271
|
+
// session cookie rides `Domain=…` instead of host-only. Verified everywhere already
|
|
272
|
+
// (one per-tenant secret, this DO); the attribute is the only thing that was missing.
|
|
273
|
+
...(cookieDomain
|
|
274
|
+
? { advanced: { crossSubDomainCookies: { enabled: true, domain: cookieDomain } } }
|
|
275
|
+
: {}),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The DO's HTTP surface (used only when Better Auth is the chosen provider). `/__session`
|
|
280
|
+
* resolves the request to an `AuthSubject`; everything else is a Better Auth request. The
|
|
281
|
+
* worker forwards requests here; Better Auth never runs in the worker.
|
|
282
|
+
*
|
|
283
|
+
* `x-substrat-cookie-domain` is the worker relaying the scope's delivered `substrat:auth`
|
|
284
|
+
* cookie-domain choice (the DO can't look it up itself — this fetch carries no scope id).
|
|
285
|
+
* The stub is reachable only from the worker, so the header is as trusted as the config;
|
|
286
|
+
* it is still re-validated against the request host before it touches a Set-Cookie.
|
|
287
|
+
*/
|
|
288
|
+
async fetch(request) {
|
|
289
|
+
const url = new URL(request.url);
|
|
290
|
+
const cookieDomain = resolveCookieDomain(request.headers.get('x-substrat-cookie-domain') ?? undefined, url.hostname);
|
|
291
|
+
const auth = this.auth(url.origin, cookieDomain);
|
|
292
|
+
if (url.pathname === '/__session') {
|
|
293
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
294
|
+
const subject = session?.user
|
|
295
|
+
? { sub: session.user.id, email: session.user.email ?? null, name: session.user.name ?? null }
|
|
296
|
+
: null;
|
|
297
|
+
return Response.json(subject);
|
|
298
|
+
}
|
|
299
|
+
return auth.handler(request);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The `AuthProvider` backed by a tenant's identity-DO stub (the `better-auth-do` config).
|
|
304
|
+
* `handle` forwards the raw request; `resolve` asks the DO's `/__session` probe, carrying
|
|
305
|
+
* the request's cookies. The worker holds only this — never Better Auth itself.
|
|
306
|
+
*
|
|
307
|
+
* `cookieDomain` (the scope's delivered `substrat:auth` choice) rides to the DO as a
|
|
308
|
+
* header because the worker holds the config and the DO holds Better Auth — see fetch().
|
|
309
|
+
*/
|
|
310
|
+
export function doAuthProvider(stub, origin, opts) {
|
|
311
|
+
const forward = (request) => {
|
|
312
|
+
if (!opts?.cookieDomain)
|
|
313
|
+
return request;
|
|
314
|
+
const relayed = new Request(request);
|
|
315
|
+
relayed.headers.set('x-substrat-cookie-domain', opts.cookieDomain);
|
|
316
|
+
return relayed;
|
|
317
|
+
};
|
|
318
|
+
return {
|
|
319
|
+
handle: (request) => stub.fetch(forward(request)),
|
|
320
|
+
async resolve(headers) {
|
|
321
|
+
const res = await stub.fetch(new Request(`${origin}/__session`, { headers }));
|
|
322
|
+
return (await res.json());
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
//# sourceMappingURL=identity-do.js.map
|