@lalternative/auth 0.16.0 → 0.18.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/{chunk-6OADPUHL.js → chunk-GACPOR2P.js} +10 -2
- package/dist/{chunk-6OADPUHL.js.map → chunk-GACPOR2P.js.map} +1 -1
- package/dist/chunk-UURHL4NP.js +177 -0
- package/dist/chunk-UURHL4NP.js.map +1 -0
- package/dist/identity-provisioning-GBE6OXR3.js +13 -0
- package/dist/identity-provisioning-GBE6OXR3.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.d.ts +101 -2
- package/dist/server.js +74 -133
- package/dist/server.js.map +1 -1
- package/dist/{sso-profile-aNyHaZHJ.d.ts → sso-profile-CJLK-hNf.d.ts} +9 -1
- package/package.json +1 -1
|
@@ -108,9 +108,16 @@ function mapSsoProfile(profile, adminRole) {
|
|
|
108
108
|
emailVerified: profile.email_verified === true,
|
|
109
109
|
name: profile.name?.trim() || email.split("@")[0] || "",
|
|
110
110
|
...profile.picture ? { image: profile.picture } : {},
|
|
111
|
-
role: rolesOf(profile).includes(adminRole) ? "admin" : "user"
|
|
111
|
+
role: rolesOf(profile).includes(adminRole) ? "admin" : "user",
|
|
112
|
+
...profile.sub ? { identityId: profile.sub } : {}
|
|
112
113
|
};
|
|
113
114
|
}
|
|
115
|
+
function identityIdFromIdToken(idToken) {
|
|
116
|
+
if (!idToken) return void 0;
|
|
117
|
+
const claims = decodeJwtPayload(idToken);
|
|
118
|
+
const sub = claims?.sub;
|
|
119
|
+
return typeof sub === "string" && sub ? sub : void 0;
|
|
120
|
+
}
|
|
114
121
|
function decodeJwtPayload(token) {
|
|
115
122
|
const part = token.split(".")[1];
|
|
116
123
|
if (!part) return void 0;
|
|
@@ -143,6 +150,7 @@ export {
|
|
|
143
150
|
invitationOutcomeCookie,
|
|
144
151
|
isInvitationFailure,
|
|
145
152
|
mapSsoProfile,
|
|
153
|
+
identityIdFromIdToken,
|
|
146
154
|
roleFromIdToken
|
|
147
155
|
};
|
|
148
|
-
//# sourceMappingURL=chunk-
|
|
156
|
+
//# sourceMappingURL=chunk-GACPOR2P.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/signup-name.ts","../src/invitation.ts","../src/sso-profile.ts"],"sourcesContent":["/**\n * Fills in the display name of an account signed up without one.\n *\n * The sign-up form treats the name as optional and omits the key when it is\n * left blank, but Better Auth's user schema requires it and rejects the\n * request with `[body.name] Invalid input`. Naming the account is the server's\n * call, so the default is applied here rather than invented by the client.\n *\n * The local part of the address is the closest thing to a name the person has\n * actually given us. It is only a starting label: they can change it later,\n * and nothing keys off it.\n */\nexport function withSignUpName<T extends { email?: unknown; name?: unknown }>(\n body: T,\n): T & { name: string } {\n const name = typeof body.name === \"string\" ? body.name.trim() : \"\"\n if (name) return { ...body, name }\n\n const email = typeof body.email === \"string\" ? body.email.trim() : \"\"\n const at = email.lastIndexOf(\"@\")\n const localPart = at > 0 ? email.slice(0, at) : email\n\n return { ...body, name: localPart }\n}\n","import type { InvitationFailure } from \"./types\"\n\n/**\n * What became of a claim, in terms the invitee can be told.\n *\n * 'expired' and 'claimed' are kept apart from 'unknown' because only they say\n * the offer was real, which is what tells someone that asking for a new link is\n * worth it rather than doubting the address they were invited at. The three\n * failures match InvitationFailure, so an outcome feeds InvitationNotice\n * directly.\n */\nexport type ClaimOutcome = \"granted\" | InvitationFailure | \"failed\"\n\nconst OUTCOME_BY_STATUS: Record<number, ClaimOutcome> = {\n 404: \"unknown\",\n 409: \"claimed\",\n 410: \"expired\",\n}\n\nexport interface ClaimInvitationOptions {\n /** Absolute URL of the endpoint that redeems a token. */\n endpoint: string\n token: string\n /** The account the app just created, which the grant is attached to. */\n externalUserId: string\n /** Sent as the Authorization bearer — typically the app's API key. */\n apiKey?: string\n /** Merged into the request body, for backends wanting more than the token. */\n extra?: Record<string, unknown>\n /** Headers merged last, so a caller can pass a cookie-based credential. */\n headers?: Record<string, string>\n /** Bounds the call so a slow API never stalls the sign-in response. */\n timeoutMs?: number\n}\n\n/**\n * Redeems an invitation token for a user who has just signed in, turning the\n * offer into a grant on their account.\n *\n * Why this belongs on the SERVER, on the auth callback rather than in the page:\n * the invitation link lands on /register?invite=<token>, but the sign-up that\n * follows can complete through any of three flows (password + OTP, OAuth\n * redirect, email verification), and only two of them return to the page that\n * held the token. Claiming where the session is established covers every flow\n * with one code path.\n *\n * Best-effort by design: a sign-in must never fail because an invitation could\n * not be redeemed. A failed claim leaves the invitation unclaimed and the user\n * on their default tier — recoverable by following the link again, since a\n * refused claim consumes nothing.\n */\nexport async function claimInvitation({\n endpoint,\n token,\n externalUserId,\n apiKey,\n extra,\n headers,\n timeoutMs = 5000,\n}: ClaimInvitationOptions): Promise<ClaimOutcome> {\n try {\n const res = await fetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),\n ...headers,\n },\n body: JSON.stringify({\n token,\n external_user_id: externalUserId,\n ...extra,\n }),\n signal: AbortSignal.timeout(timeoutMs),\n })\n\n if (res.ok) return \"granted\"\n return OUTCOME_BY_STATUS[res.status] ?? \"failed\"\n } catch {\n return \"failed\"\n }\n}\n\n/**\n * Extracts the invitation token from an auth request.\n *\n * The token lives on the page the invitee landed on (/register?invite=…), never\n * on the auth endpoints themselves, so it has to be recovered from the request\n * that completes the sign-up. Three sources, in order of trust:\n *\n * - the URL, for a callback reached through a redirect whose query string\n * the app controls;\n * - the cookie held since the link was opened, which is the only one that\n * survives an OAuth round trip or an OTP screen that never carried the\n * token (see holdInviteTokenCookie);\n * - the Referer, for the password flow, whose XHR is issued BY the page\n * holding it.\n *\n * The Referer stays last and stays supported: it covers a caller that never\n * held the cookie. It is attacker-controlled input on a best-effort path, so a\n * malformed one is ignored rather than thrown on.\n */\nexport function inviteTokenFrom(\n request: Request,\n param = \"invite\",\n): string | null {\n const direct = new URL(request.url).searchParams.get(param)\n if (direct && direct.trim() !== \"\") return direct\n\n const held = inviteTokenCookie(request)\n if (held) return held\n\n const referer = request.headers.get(\"referer\")\n if (!referer) return null\n try {\n const token = new URL(referer).searchParams.get(param)\n return token && token.trim() !== \"\" ? token : null\n } catch {\n return null\n }\n}\n\n/** Name of the cookie holding the token between the link and the claim. */\nconst INVITE_TOKEN_COOKIE = \"invite_token\"\n\n/**\n * Pins the token onto the browser the first time a request carries it, so the\n * rest of the sign-up can find it.\n *\n * Called on every auth request, not only the ones completing a sign-up: the\n * token is legible on the FIRST call of a flow (the page holding it issues that\n * XHR, so the Referer still has it) and gone by the last (verified from a\n * screen that never held it, or returned from Google). Waiting for the moment\n * the account exists is waiting one request too long.\n *\n * Returns null when there is nothing to pin — no token in the request, or one\n * already held — so a caller can skip the Set-Cookie entirely.\n */\nexport function pinInviteToken(\n request: Request,\n param = \"invite\",\n): string | null {\n if (inviteTokenCookie(request)) return null\n const token = inviteTokenFrom(request, param)\n return token ? holdInviteTokenCookie(token) : null\n}\n\n/**\n * Holds the token from the moment the link is opened until the account exists.\n *\n * The URL and the Referer each cover only part of the ground: the OTP flow\n * verifies from a screen that never carried the token, and an OAuth sign-up\n * comes back from Google with no Referer of ours at all. Both lose it, and the\n * invitee lands on the default tier with the offer still pending.\n *\n * SameSite=Lax rather than Strict: the return from Google is a cross-site\n * top-level navigation, which Strict would refuse — the one case this exists\n * for. HttpOnly because the page has no reason to read it, and short-lived\n * because signing up takes minutes: a single-use invitation has no business\n * sitting in a browser for longer.\n */\nexport function holdInviteTokenCookie(\n token: string,\n maxAgeSeconds = 1800,\n): string {\n return `${INVITE_TOKEN_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax`\n}\n\n/**\n * Clears the held token. Sent once the claim has been attempted: the token is\n * single-use, so keeping it would only replay a call that can no longer\n * succeed.\n */\nexport function releaseInviteTokenCookie(): string {\n return `${INVITE_TOKEN_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`\n}\n\nfunction inviteTokenCookie(request: Request): string | null {\n const header = request.headers.get(\"cookie\")\n if (!header) return null\n for (const part of header.split(\";\")) {\n const [name, ...rest] = part.trim().split(\"=\")\n if (name !== INVITE_TOKEN_COOKIE) continue\n const value = decodeURIComponent(rest.join(\"=\")).trim()\n return value === \"\" ? null : value\n }\n return null\n}\n\n/**\n * BetterAuth paths that complete a sign-up. Email/password returns the user\n * straight from sign-up/email; the OTP and OAuth flows only produce a usable\n * account once the verification/callback succeeds, so those are the moments\n * worth reacting to.\n */\nconst SIGNUP_COMPLETING = [\n \"/sign-up/email\",\n \"/sign-in/email-otp\",\n \"/email-otp/verify-email\",\n \"/callback/\",\n]\n\n/**\n * Whether this request is the one that just created a usable account — the\n * moment to provision, claim an invitation, or greet someone. Matching on the\n * path rather than on a response body keeps it flow-agnostic: the three\n * sign-up flows return three different shapes.\n */\nexport function completesSignup(pathname: string): boolean {\n return SIGNUP_COMPLETING.some((p) => pathname.includes(p))\n}\n\n/**\n * Carries a failed claim to the next page. The claim happens inside an auth\n * response nobody renders, so its result would otherwise reach only the server\n * log — leaving an invitee on the default tier with no idea their link had\n * lapsed. Short-lived and readable by the page, which reports it and clears it.\n */\nexport function invitationOutcomeCookie(\n outcome: ClaimOutcome,\n name = \"invite_claim\",\n): string {\n return `${name}=${outcome}; Path=/; Max-Age=120; SameSite=Lax`\n}\n\n/**\n * Whether an outcome is one the invitee should be shown a reason for.\n * 'failed' is excluded: it means the call did not complete, so the offer may\n * still be good and telling someone their invitation is invalid would be wrong.\n */\nexport function isInvitationFailure(\n outcome: ClaimOutcome,\n): outcome is InvitationFailure {\n return outcome === \"expired\" || outcome === \"claimed\" || outcome === \"unknown\"\n}\n","export interface SsoProfile {\n sub?: string\n email?: string\n email_verified?: boolean\n name?: string\n picture?: string\n roles?: unknown\n}\n\nexport interface SsoMappedUser {\n [key: string]: unknown\n email: string\n emailVerified: boolean\n name: string\n image?: string\n role: \"admin\" | \"user\"\n}\n\nexport function rolesOf(profile: SsoProfile): string[] {\n return Array.isArray(profile.roles)\n ? profile.roles.filter((r): r is string => typeof r === \"string\")\n : []\n}\n\n/**\n * Maps the identity provider's claims onto the local user. The admin role is\n * recomputed from the roles claim on every sign-in, so a role removed at the\n * provider is removed here the next time the person signs in.\n */\nexport function mapSsoProfile(profile: SsoProfile, adminRole: string): SsoMappedUser {\n const email = (profile.email ?? \"\").trim().toLowerCase()\n return {\n email,\n emailVerified: profile.email_verified === true,\n name: profile.name?.trim() || email.split(\"@\")[0] || \"\",\n ...(profile.picture ? { image: profile.picture } : {}),\n role: rolesOf(profile).includes(adminRole) ? \"admin\" : \"user\",\n }\n}\n\nfunction decodeJwtPayload(token: string): Record<string, unknown> | undefined {\n const part = token.split(\".\")[1]\n if (!part) return undefined\n try {\n const binary = atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\"))\n const json = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)))\n const parsed: unknown = JSON.parse(json)\n return parsed && typeof parsed === \"object\" ? (parsed as Record<string, unknown>) : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * The local role an ID token from the identity provider grants, or undefined\n * when the token cannot be read, so a broken token never demotes anyone by\n * accident. Roles are read from the top-level claim or from Hydra's `ext`.\n */\nexport function roleFromIdToken(idToken: string | null | undefined, adminRole: string): \"admin\" | \"user\" | undefined {\n if (!idToken) return undefined\n const claims = decodeJwtPayload(idToken)\n if (!claims) return undefined\n const ext = claims.ext\n const raw = Array.isArray(claims.roles)\n ? claims.roles\n : ext && typeof ext === \"object\" && Array.isArray((ext as { roles?: unknown }).roles)\n ? (ext as { roles: unknown[] }).roles\n : []\n return raw.includes(adminRole) ? \"admin\" : \"user\"\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/signup-name.ts","../src/invitation.ts","../src/sso-profile.ts"],"sourcesContent":["/**\n * Fills in the display name of an account signed up without one.\n *\n * The sign-up form treats the name as optional and omits the key when it is\n * left blank, but Better Auth's user schema requires it and rejects the\n * request with `[body.name] Invalid input`. Naming the account is the server's\n * call, so the default is applied here rather than invented by the client.\n *\n * The local part of the address is the closest thing to a name the person has\n * actually given us. It is only a starting label: they can change it later,\n * and nothing keys off it.\n */\nexport function withSignUpName<T extends { email?: unknown; name?: unknown }>(\n body: T,\n): T & { name: string } {\n const name = typeof body.name === \"string\" ? body.name.trim() : \"\"\n if (name) return { ...body, name }\n\n const email = typeof body.email === \"string\" ? body.email.trim() : \"\"\n const at = email.lastIndexOf(\"@\")\n const localPart = at > 0 ? email.slice(0, at) : email\n\n return { ...body, name: localPart }\n}\n","import type { InvitationFailure } from \"./types\"\n\n/**\n * What became of a claim, in terms the invitee can be told.\n *\n * 'expired' and 'claimed' are kept apart from 'unknown' because only they say\n * the offer was real, which is what tells someone that asking for a new link is\n * worth it rather than doubting the address they were invited at. The three\n * failures match InvitationFailure, so an outcome feeds InvitationNotice\n * directly.\n */\nexport type ClaimOutcome = \"granted\" | InvitationFailure | \"failed\"\n\nconst OUTCOME_BY_STATUS: Record<number, ClaimOutcome> = {\n 404: \"unknown\",\n 409: \"claimed\",\n 410: \"expired\",\n}\n\nexport interface ClaimInvitationOptions {\n /** Absolute URL of the endpoint that redeems a token. */\n endpoint: string\n token: string\n /** The account the app just created, which the grant is attached to. */\n externalUserId: string\n /** Sent as the Authorization bearer — typically the app's API key. */\n apiKey?: string\n /** Merged into the request body, for backends wanting more than the token. */\n extra?: Record<string, unknown>\n /** Headers merged last, so a caller can pass a cookie-based credential. */\n headers?: Record<string, string>\n /** Bounds the call so a slow API never stalls the sign-in response. */\n timeoutMs?: number\n}\n\n/**\n * Redeems an invitation token for a user who has just signed in, turning the\n * offer into a grant on their account.\n *\n * Why this belongs on the SERVER, on the auth callback rather than in the page:\n * the invitation link lands on /register?invite=<token>, but the sign-up that\n * follows can complete through any of three flows (password + OTP, OAuth\n * redirect, email verification), and only two of them return to the page that\n * held the token. Claiming where the session is established covers every flow\n * with one code path.\n *\n * Best-effort by design: a sign-in must never fail because an invitation could\n * not be redeemed. A failed claim leaves the invitation unclaimed and the user\n * on their default tier — recoverable by following the link again, since a\n * refused claim consumes nothing.\n */\nexport async function claimInvitation({\n endpoint,\n token,\n externalUserId,\n apiKey,\n extra,\n headers,\n timeoutMs = 5000,\n}: ClaimInvitationOptions): Promise<ClaimOutcome> {\n try {\n const res = await fetch(endpoint, {\n method: \"POST\",\n headers: {\n \"content-type\": \"application/json\",\n ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),\n ...headers,\n },\n body: JSON.stringify({\n token,\n external_user_id: externalUserId,\n ...extra,\n }),\n signal: AbortSignal.timeout(timeoutMs),\n })\n\n if (res.ok) return \"granted\"\n return OUTCOME_BY_STATUS[res.status] ?? \"failed\"\n } catch {\n return \"failed\"\n }\n}\n\n/**\n * Extracts the invitation token from an auth request.\n *\n * The token lives on the page the invitee landed on (/register?invite=…), never\n * on the auth endpoints themselves, so it has to be recovered from the request\n * that completes the sign-up. Three sources, in order of trust:\n *\n * - the URL, for a callback reached through a redirect whose query string\n * the app controls;\n * - the cookie held since the link was opened, which is the only one that\n * survives an OAuth round trip or an OTP screen that never carried the\n * token (see holdInviteTokenCookie);\n * - the Referer, for the password flow, whose XHR is issued BY the page\n * holding it.\n *\n * The Referer stays last and stays supported: it covers a caller that never\n * held the cookie. It is attacker-controlled input on a best-effort path, so a\n * malformed one is ignored rather than thrown on.\n */\nexport function inviteTokenFrom(\n request: Request,\n param = \"invite\",\n): string | null {\n const direct = new URL(request.url).searchParams.get(param)\n if (direct && direct.trim() !== \"\") return direct\n\n const held = inviteTokenCookie(request)\n if (held) return held\n\n const referer = request.headers.get(\"referer\")\n if (!referer) return null\n try {\n const token = new URL(referer).searchParams.get(param)\n return token && token.trim() !== \"\" ? token : null\n } catch {\n return null\n }\n}\n\n/** Name of the cookie holding the token between the link and the claim. */\nconst INVITE_TOKEN_COOKIE = \"invite_token\"\n\n/**\n * Pins the token onto the browser the first time a request carries it, so the\n * rest of the sign-up can find it.\n *\n * Called on every auth request, not only the ones completing a sign-up: the\n * token is legible on the FIRST call of a flow (the page holding it issues that\n * XHR, so the Referer still has it) and gone by the last (verified from a\n * screen that never held it, or returned from Google). Waiting for the moment\n * the account exists is waiting one request too long.\n *\n * Returns null when there is nothing to pin — no token in the request, or one\n * already held — so a caller can skip the Set-Cookie entirely.\n */\nexport function pinInviteToken(\n request: Request,\n param = \"invite\",\n): string | null {\n if (inviteTokenCookie(request)) return null\n const token = inviteTokenFrom(request, param)\n return token ? holdInviteTokenCookie(token) : null\n}\n\n/**\n * Holds the token from the moment the link is opened until the account exists.\n *\n * The URL and the Referer each cover only part of the ground: the OTP flow\n * verifies from a screen that never carried the token, and an OAuth sign-up\n * comes back from Google with no Referer of ours at all. Both lose it, and the\n * invitee lands on the default tier with the offer still pending.\n *\n * SameSite=Lax rather than Strict: the return from Google is a cross-site\n * top-level navigation, which Strict would refuse — the one case this exists\n * for. HttpOnly because the page has no reason to read it, and short-lived\n * because signing up takes minutes: a single-use invitation has no business\n * sitting in a browser for longer.\n */\nexport function holdInviteTokenCookie(\n token: string,\n maxAgeSeconds = 1800,\n): string {\n return `${INVITE_TOKEN_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax`\n}\n\n/**\n * Clears the held token. Sent once the claim has been attempted: the token is\n * single-use, so keeping it would only replay a call that can no longer\n * succeed.\n */\nexport function releaseInviteTokenCookie(): string {\n return `${INVITE_TOKEN_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`\n}\n\nfunction inviteTokenCookie(request: Request): string | null {\n const header = request.headers.get(\"cookie\")\n if (!header) return null\n for (const part of header.split(\";\")) {\n const [name, ...rest] = part.trim().split(\"=\")\n if (name !== INVITE_TOKEN_COOKIE) continue\n const value = decodeURIComponent(rest.join(\"=\")).trim()\n return value === \"\" ? null : value\n }\n return null\n}\n\n/**\n * BetterAuth paths that complete a sign-up. Email/password returns the user\n * straight from sign-up/email; the OTP and OAuth flows only produce a usable\n * account once the verification/callback succeeds, so those are the moments\n * worth reacting to.\n */\nconst SIGNUP_COMPLETING = [\n \"/sign-up/email\",\n \"/sign-in/email-otp\",\n \"/email-otp/verify-email\",\n \"/callback/\",\n]\n\n/**\n * Whether this request is the one that just created a usable account — the\n * moment to provision, claim an invitation, or greet someone. Matching on the\n * path rather than on a response body keeps it flow-agnostic: the three\n * sign-up flows return three different shapes.\n */\nexport function completesSignup(pathname: string): boolean {\n return SIGNUP_COMPLETING.some((p) => pathname.includes(p))\n}\n\n/**\n * Carries a failed claim to the next page. The claim happens inside an auth\n * response nobody renders, so its result would otherwise reach only the server\n * log — leaving an invitee on the default tier with no idea their link had\n * lapsed. Short-lived and readable by the page, which reports it and clears it.\n */\nexport function invitationOutcomeCookie(\n outcome: ClaimOutcome,\n name = \"invite_claim\",\n): string {\n return `${name}=${outcome}; Path=/; Max-Age=120; SameSite=Lax`\n}\n\n/**\n * Whether an outcome is one the invitee should be shown a reason for.\n * 'failed' is excluded: it means the call did not complete, so the offer may\n * still be good and telling someone their invitation is invalid would be wrong.\n */\nexport function isInvitationFailure(\n outcome: ClaimOutcome,\n): outcome is InvitationFailure {\n return outcome === \"expired\" || outcome === \"claimed\" || outcome === \"unknown\"\n}\n","export interface SsoProfile {\n sub?: string\n email?: string\n email_verified?: boolean\n name?: string\n picture?: string\n roles?: unknown\n}\n\nexport interface SsoMappedUser {\n [key: string]: unknown\n email: string\n emailVerified: boolean\n name: string\n image?: string\n role: \"admin\" | \"user\"\n identityId?: string\n}\n\nexport function rolesOf(profile: SsoProfile): string[] {\n return Array.isArray(profile.roles)\n ? profile.roles.filter((r): r is string => typeof r === \"string\")\n : []\n}\n\n/**\n * Maps the identity provider's claims onto the local user. The admin role is\n * recomputed from the roles claim on every sign-in, so a role removed at the\n * provider is removed here the next time the person signs in.\n */\nexport function mapSsoProfile(profile: SsoProfile, adminRole: string): SsoMappedUser {\n const email = (profile.email ?? \"\").trim().toLowerCase()\n return {\n email,\n emailVerified: profile.email_verified === true,\n name: profile.name?.trim() || email.split(\"@\")[0] || \"\",\n ...(profile.picture ? { image: profile.picture } : {}),\n role: rolesOf(profile).includes(adminRole) ? \"admin\" : \"user\",\n ...(profile.sub ? { identityId: profile.sub } : {}),\n }\n}\n\n/**\n * The provider's identity id an ID token names, or undefined when the token\n * cannot be read. It is the `sub` claim: the suite addresses a person by it,\n * and an app key is issued against it, so a local row without one cannot ask\n * for a key on that person's behalf.\n */\nexport function identityIdFromIdToken(idToken: string | null | undefined): string | undefined {\n if (!idToken) return undefined\n const claims = decodeJwtPayload(idToken)\n const sub = claims?.sub\n return typeof sub === \"string\" && sub ? sub : undefined\n}\n\nfunction decodeJwtPayload(token: string): Record<string, unknown> | undefined {\n const part = token.split(\".\")[1]\n if (!part) return undefined\n try {\n const binary = atob(part.replace(/-/g, \"+\").replace(/_/g, \"/\"))\n const json = new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)))\n const parsed: unknown = JSON.parse(json)\n return parsed && typeof parsed === \"object\" ? (parsed as Record<string, unknown>) : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * The local role an ID token from the identity provider grants, or undefined\n * when the token cannot be read, so a broken token never demotes anyone by\n * accident. Roles are read from the top-level claim or from Hydra's `ext`.\n */\nexport function roleFromIdToken(idToken: string | null | undefined, adminRole: string): \"admin\" | \"user\" | undefined {\n if (!idToken) return undefined\n const claims = decodeJwtPayload(idToken)\n if (!claims) return undefined\n const ext = claims.ext\n const raw = Array.isArray(claims.roles)\n ? claims.roles\n : ext && typeof ext === \"object\" && Array.isArray((ext as { roles?: unknown }).roles)\n ? (ext as { roles: unknown[] }).roles\n : []\n return raw.includes(adminRole) ? \"admin\" : \"user\"\n}\n"],"mappings":";AAYO,SAAS,eACd,MACsB;AACtB,QAAM,OAAO,OAAO,KAAK,SAAS,WAAW,KAAK,KAAK,KAAK,IAAI;AAChE,MAAI,KAAM,QAAO,EAAE,GAAG,MAAM,KAAK;AAEjC,QAAM,QAAQ,OAAO,KAAK,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;AACnE,QAAM,KAAK,MAAM,YAAY,GAAG;AAChC,QAAM,YAAY,KAAK,IAAI,MAAM,MAAM,GAAG,EAAE,IAAI;AAEhD,SAAO,EAAE,GAAG,MAAM,MAAM,UAAU;AACpC;;;ACVA,IAAM,oBAAkD;AAAA,EACtD,KAAK;AAAA,EACL,KAAK;AAAA,EACL,KAAK;AACP;AAkCA,eAAsB,gBAAgB;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AACd,GAAkD;AAChD,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,UAAU;AAAA,MAChC,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,GAAI,SAAS,EAAE,eAAe,UAAU,MAAM,GAAG,IAAI,CAAC;AAAA,QACtD,GAAG;AAAA,MACL;AAAA,MACA,MAAM,KAAK,UAAU;AAAA,QACnB;AAAA,QACA,kBAAkB;AAAA,QAClB,GAAG;AAAA,MACL,CAAC;AAAA,MACD,QAAQ,YAAY,QAAQ,SAAS;AAAA,IACvC,CAAC;AAED,QAAI,IAAI,GAAI,QAAO;AACnB,WAAO,kBAAkB,IAAI,MAAM,KAAK;AAAA,EAC1C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAqBO,SAAS,gBACd,SACA,QAAQ,UACO;AACf,QAAM,SAAS,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IAAI,KAAK;AAC1D,MAAI,UAAU,OAAO,KAAK,MAAM,GAAI,QAAO;AAE3C,QAAM,OAAO,kBAAkB,OAAO;AACtC,MAAI,KAAM,QAAO;AAEjB,QAAM,UAAU,QAAQ,QAAQ,IAAI,SAAS;AAC7C,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI;AACF,UAAM,QAAQ,IAAI,IAAI,OAAO,EAAE,aAAa,IAAI,KAAK;AACrD,WAAO,SAAS,MAAM,KAAK,MAAM,KAAK,QAAQ;AAAA,EAChD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,IAAM,sBAAsB;AAerB,SAAS,eACd,SACA,QAAQ,UACO;AACf,MAAI,kBAAkB,OAAO,EAAG,QAAO;AACvC,QAAM,QAAQ,gBAAgB,SAAS,KAAK;AAC5C,SAAO,QAAQ,sBAAsB,KAAK,IAAI;AAChD;AAgBO,SAAS,sBACd,OACA,gBAAgB,MACR;AACR,SAAO,GAAG,mBAAmB,IAAI,mBAAmB,KAAK,CAAC,qBAAqB,aAAa;AAC9F;AAOO,SAAS,2BAAmC;AACjD,SAAO,GAAG,mBAAmB;AAC/B;AAEA,SAAS,kBAAkB,SAAiC;AAC1D,QAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ;AAC3C,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAW,QAAQ,OAAO,MAAM,GAAG,GAAG;AACpC,UAAM,CAAC,MAAM,GAAG,IAAI,IAAI,KAAK,KAAK,EAAE,MAAM,GAAG;AAC7C,QAAI,SAAS,oBAAqB;AAClC,UAAM,QAAQ,mBAAmB,KAAK,KAAK,GAAG,CAAC,EAAE,KAAK;AACtD,WAAO,UAAU,KAAK,OAAO;AAAA,EAC/B;AACA,SAAO;AACT;AAQA,IAAM,oBAAoB;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAQO,SAAS,gBAAgB,UAA2B;AACzD,SAAO,kBAAkB,KAAK,CAAC,MAAM,SAAS,SAAS,CAAC,CAAC;AAC3D;AAQO,SAAS,wBACd,SACA,OAAO,gBACC;AACR,SAAO,GAAG,IAAI,IAAI,OAAO;AAC3B;AAOO,SAAS,oBACd,SAC8B;AAC9B,SAAO,YAAY,aAAa,YAAY,aAAa,YAAY;AACvE;;;ACvNO,SAAS,QAAQ,SAA+B;AACrD,SAAO,MAAM,QAAQ,QAAQ,KAAK,IAC9B,QAAQ,MAAM,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ,IAC9D,CAAC;AACP;AAOO,SAAS,cAAc,SAAqB,WAAkC;AACnF,QAAM,SAAS,QAAQ,SAAS,IAAI,KAAK,EAAE,YAAY;AACvD,SAAO;AAAA,IACL;AAAA,IACA,eAAe,QAAQ,mBAAmB;AAAA,IAC1C,MAAM,QAAQ,MAAM,KAAK,KAAK,MAAM,MAAM,GAAG,EAAE,CAAC,KAAK;AAAA,IACrD,GAAI,QAAQ,UAAU,EAAE,OAAO,QAAQ,QAAQ,IAAI,CAAC;AAAA,IACpD,MAAM,QAAQ,OAAO,EAAE,SAAS,SAAS,IAAI,UAAU;AAAA,IACvD,GAAI,QAAQ,MAAM,EAAE,YAAY,QAAQ,IAAI,IAAI,CAAC;AAAA,EACnD;AACF;AAQO,SAAS,sBAAsB,SAAwD;AAC5F,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,SAAS,iBAAiB,OAAO;AACvC,QAAM,MAAM,QAAQ;AACpB,SAAO,OAAO,QAAQ,YAAY,MAAM,MAAM;AAChD;AAEA,SAAS,iBAAiB,OAAoD;AAC5E,QAAM,OAAO,MAAM,MAAM,GAAG,EAAE,CAAC;AAC/B,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI;AACF,UAAM,SAAS,KAAK,KAAK,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,CAAC;AAC9D,UAAM,OAAO,IAAI,YAAY,EAAE,OAAO,WAAW,KAAK,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;AACrF,UAAM,SAAkB,KAAK,MAAM,IAAI;AACvC,WAAO,UAAU,OAAO,WAAW,WAAY,SAAqC;AAAA,EACtF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOO,SAAS,gBAAgB,SAAoC,WAAiD;AACnH,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,SAAS,iBAAiB,OAAO;AACvC,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,MAAM,OAAO;AACnB,QAAM,MAAM,MAAM,QAAQ,OAAO,KAAK,IAClC,OAAO,QACP,OAAO,OAAO,QAAQ,YAAY,MAAM,QAAS,IAA4B,KAAK,IAC/E,IAA6B,QAC9B,CAAC;AACP,SAAO,IAAI,SAAS,SAAS,IAAI,UAAU;AAC7C;","names":[]}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/identity-provisioning.ts
|
|
2
|
+
var REQUEST_TIMEOUT_MS = 5e3;
|
|
3
|
+
var TOKEN_EXPIRY_MARGIN_S = 30;
|
|
4
|
+
var tokenCache = /* @__PURE__ */ new Map();
|
|
5
|
+
function resetProvisioningTokenCache() {
|
|
6
|
+
tokenCache.clear();
|
|
7
|
+
}
|
|
8
|
+
async function accessToken(config, fetchImpl) {
|
|
9
|
+
const key = `${config.issuer}|${config.clientId}`;
|
|
10
|
+
const cached = tokenCache.get(key);
|
|
11
|
+
const now = Date.now() / 1e3;
|
|
12
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
13
|
+
const base = config.issuer.replace(/\/$/, "");
|
|
14
|
+
const credentials = btoa(`${config.clientId}:${config.clientSecret}`);
|
|
15
|
+
try {
|
|
16
|
+
const response = await fetchImpl(`${base}/oauth2/token`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Basic ${credentials}`,
|
|
20
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
21
|
+
},
|
|
22
|
+
body: new URLSearchParams({
|
|
23
|
+
grant_type: "client_credentials",
|
|
24
|
+
audience: "urbangate",
|
|
25
|
+
scope: "urbangate:identities:provision"
|
|
26
|
+
}).toString(),
|
|
27
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) return void 0;
|
|
30
|
+
const body = await response.json();
|
|
31
|
+
if (!body.access_token) return void 0;
|
|
32
|
+
tokenCache.set(key, {
|
|
33
|
+
value: body.access_token,
|
|
34
|
+
expiresAt: now + (body.expires_in ?? 900) - TOKEN_EXPIRY_MARGIN_S
|
|
35
|
+
});
|
|
36
|
+
return body.access_token;
|
|
37
|
+
} catch {
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function provisionIdentity(config, request, fetchImpl = fetch) {
|
|
42
|
+
const token = await accessToken(config, fetchImpl);
|
|
43
|
+
if (!token) return { status: "unavailable" };
|
|
44
|
+
const base = config.issuer.replace(/\/$/, "");
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetchImpl(`${base}/api/machine/identities`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
"Content-Type": "application/json"
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
email: request.email.trim().toLowerCase(),
|
|
54
|
+
email_verified: true,
|
|
55
|
+
name: request.name ?? "",
|
|
56
|
+
role: config.role,
|
|
57
|
+
product: config.product,
|
|
58
|
+
...request.password ? { password: request.password } : {}
|
|
59
|
+
}),
|
|
60
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
61
|
+
});
|
|
62
|
+
if (response.ok) {
|
|
63
|
+
const body2 = await response.json();
|
|
64
|
+
if (!body2.identity_id) return { status: "unavailable" };
|
|
65
|
+
return {
|
|
66
|
+
status: "provisioned",
|
|
67
|
+
identityId: body2.identity_id,
|
|
68
|
+
created: body2.created === true
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (response.status === 503 || response.status >= 500) {
|
|
72
|
+
return { status: "unavailable" };
|
|
73
|
+
}
|
|
74
|
+
if (response.status === 401) {
|
|
75
|
+
tokenCache.delete(`${config.issuer}|${config.clientId}`);
|
|
76
|
+
return { status: "unavailable" };
|
|
77
|
+
}
|
|
78
|
+
const body = await response.json().catch(() => ({}));
|
|
79
|
+
return {
|
|
80
|
+
status: "rejected",
|
|
81
|
+
reason: body.error ?? body.message ?? `http_${response.status}`
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return { status: "unavailable" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function updateIdentityPassword(config, request, fetchImpl = fetch) {
|
|
88
|
+
const token = await accessToken(config, fetchImpl);
|
|
89
|
+
if (!token) return { status: "unavailable" };
|
|
90
|
+
const base = config.issuer.replace(/\/$/, "");
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetchImpl(`${base}/api/machine/passwords`, {
|
|
93
|
+
method: "PUT",
|
|
94
|
+
headers: {
|
|
95
|
+
Authorization: `Bearer ${token}`,
|
|
96
|
+
"Content-Type": "application/json"
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
email: request.email.trim().toLowerCase(),
|
|
100
|
+
password: request.password
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
103
|
+
});
|
|
104
|
+
if (response.ok) {
|
|
105
|
+
const body2 = await response.json();
|
|
106
|
+
if (!body2.identity_id) return { status: "unavailable" };
|
|
107
|
+
return {
|
|
108
|
+
status: "provisioned",
|
|
109
|
+
identityId: body2.identity_id,
|
|
110
|
+
created: false
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (response.status === 503 || response.status >= 500) {
|
|
114
|
+
return { status: "unavailable" };
|
|
115
|
+
}
|
|
116
|
+
if (response.status === 401) {
|
|
117
|
+
tokenCache.delete(`${config.issuer}|${config.clientId}`);
|
|
118
|
+
return { status: "unavailable" };
|
|
119
|
+
}
|
|
120
|
+
if (response.status === 404) {
|
|
121
|
+
return { status: "rejected", reason: "not_found" };
|
|
122
|
+
}
|
|
123
|
+
const body = await response.json().catch(() => ({}));
|
|
124
|
+
return {
|
|
125
|
+
status: "rejected",
|
|
126
|
+
reason: body.error ?? body.message ?? `http_${response.status}`
|
|
127
|
+
};
|
|
128
|
+
} catch {
|
|
129
|
+
return { status: "unavailable" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function requestAccountDeletion(config, request, fetchImpl = fetch) {
|
|
133
|
+
const token = await accessToken(config, fetchImpl);
|
|
134
|
+
if (!token) return { status: "unavailable" };
|
|
135
|
+
const base = config.issuer.replace(/\/$/, "");
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetchImpl(`${base}/api/v1/machine/accounts/deletions`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
Authorization: `Bearer ${token}`,
|
|
141
|
+
"Content-Type": "application/json"
|
|
142
|
+
},
|
|
143
|
+
// No product field: urbangate reads it from the token's client_id, so a
|
|
144
|
+
// token cannot ask for an account it does not own.
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
identity_id: request.identityId,
|
|
147
|
+
...request.userId ? { user_id: request.userId } : {}
|
|
148
|
+
}),
|
|
149
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
150
|
+
});
|
|
151
|
+
if (response.ok) {
|
|
152
|
+
const body2 = await response.json();
|
|
153
|
+
return { status: "requested", eventId: body2.event_id ?? "" };
|
|
154
|
+
}
|
|
155
|
+
if (response.status >= 500) return { status: "unavailable" };
|
|
156
|
+
if (response.status === 401) {
|
|
157
|
+
tokenCache.delete(`${config.issuer}|${config.clientId}`);
|
|
158
|
+
return { status: "unavailable" };
|
|
159
|
+
}
|
|
160
|
+
if (response.status === 404) return { status: "requested", eventId: "" };
|
|
161
|
+
const body = await response.json().catch(() => ({}));
|
|
162
|
+
return {
|
|
163
|
+
status: "rejected",
|
|
164
|
+
reason: body.error ?? body.message ?? `http_${response.status}`
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return { status: "unavailable" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export {
|
|
172
|
+
resetProvisioningTokenCache,
|
|
173
|
+
provisionIdentity,
|
|
174
|
+
updateIdentityPassword,
|
|
175
|
+
requestAccountDeletion
|
|
176
|
+
};
|
|
177
|
+
//# sourceMappingURL=chunk-UURHL4NP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/identity-provisioning.ts"],"sourcesContent":["export interface IdentityProvisioningConfig {\n /** urbangate's issuer URL, e.g. https://id.urbangate.dev */\n issuer: string\n /** The product's provisioner client, e.g. \"spore-provisioner\". */\n clientId: string\n clientSecret: string\n /** The role granted on provisioning, e.g. \"spore:user\". */\n role: string\n /** The product id carried in the request, e.g. \"spore\". */\n product: string\n}\n\nexport type ProvisionOutcome =\n | { status: \"provisioned\"; identityId: string; created: boolean }\n | { status: \"rejected\"; reason: string }\n | { status: \"unavailable\" }\n\nexport interface ProvisionRequest {\n email: string\n name?: string\n /**\n * The password the person just typed on the product's own form. Kratos\n * hashes it with the hasher its configuration declares, so an app cannot\n * hand over one it hashed itself; it is relayed for the length of this\n * request and stored nowhere. Omitted, the identity is created without a\n * credential and its owner sets one through recovery.\n */\n password?: string\n}\n\ninterface TokenResponse {\n access_token?: string\n expires_in?: number\n}\n\ninterface IdentityResponse {\n identity_id?: string\n created?: boolean\n}\n\nconst REQUEST_TIMEOUT_MS = 5000\nconst TOKEN_EXPIRY_MARGIN_S = 30\n\ninterface CachedToken {\n value: string\n expiresAt: number\n}\n\nconst tokenCache = new Map<string, CachedToken>()\n\nexport function resetProvisioningTokenCache(): void {\n tokenCache.clear()\n}\n\nasync function accessToken(\n config: IdentityProvisioningConfig,\n fetchImpl: typeof fetch,\n): Promise<string | undefined> {\n const key = `${config.issuer}|${config.clientId}`\n const cached = tokenCache.get(key)\n const now = Date.now() / 1000\n if (cached && cached.expiresAt > now) return cached.value\n\n const base = config.issuer.replace(/\\/$/, \"\")\n const credentials = btoa(`${config.clientId}:${config.clientSecret}`)\n try {\n const response = await fetchImpl(`${base}/oauth2/token`, {\n method: \"POST\",\n headers: {\n Authorization: `Basic ${credentials}`,\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n audience: \"urbangate\",\n scope: \"urbangate:identities:provision\",\n }).toString(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n if (!response.ok) return undefined\n const body = (await response.json()) as TokenResponse\n if (!body.access_token) return undefined\n tokenCache.set(key, {\n value: body.access_token,\n expiresAt: now + (body.expires_in ?? 900) - TOKEN_EXPIRY_MARGIN_S,\n })\n return body.access_token\n } catch {\n return undefined\n }\n}\n\n/**\n * Creates or joins the person's identity at urbangate, returning the id the\n * local user row stores.\n *\n * The endpoint is idempotent on the address: a person who already has an\n * identity through another product of the suite gets that same one, with this\n * product's role added. The products therefore never own the identity, only\n * their role on it — a product deleting its local account must drop its role,\n * never deactivate the identity, or it would sign the person out of every\n * other product of the suite.\n */\nexport async function provisionIdentity(\n config: IdentityProvisioningConfig,\n request: ProvisionRequest,\n fetchImpl: typeof fetch = fetch,\n): Promise<ProvisionOutcome> {\n const token = await accessToken(config, fetchImpl)\n if (!token) return { status: \"unavailable\" }\n\n const base = config.issuer.replace(/\\/$/, \"\")\n try {\n const response = await fetchImpl(`${base}/api/machine/identities`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n email: request.email.trim().toLowerCase(),\n email_verified: true,\n name: request.name ?? \"\",\n role: config.role,\n product: config.product,\n ...(request.password ? { password: request.password } : {}),\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n\n if (response.ok) {\n const body = (await response.json()) as IdentityResponse\n if (!body.identity_id) return { status: \"unavailable\" }\n return {\n status: \"provisioned\",\n identityId: body.identity_id,\n created: body.created === true,\n }\n }\n\n // 503 is the endpoint's retryable answer, including the inconclusive\n // lookup that refuses to risk a duplicate identity.\n if (response.status === 503 || response.status >= 500) {\n return { status: \"unavailable\" }\n }\n if (response.status === 401) {\n tokenCache.delete(`${config.issuer}|${config.clientId}`)\n return { status: \"unavailable\" }\n }\n const body = (await response.json().catch(() => ({}))) as {\n error?: string\n message?: string\n }\n return {\n status: \"rejected\",\n reason: body.error ?? body.message ?? `http_${response.status}`,\n }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n\n/**\n * Sets the password of an identity the product already enrols, for a reset or\n * a change made on the product's own form.\n *\n * `rejected` with reason `not_found` is the person having no identity yet —\n * a local account that predates the move, or one whose provisioning is still\n * to be repaired — and is worth provisioning rather than retrying.\n */\nexport async function updateIdentityPassword(\n config: IdentityProvisioningConfig,\n request: { email: string; password: string },\n fetchImpl: typeof fetch = fetch,\n): Promise<ProvisionOutcome> {\n const token = await accessToken(config, fetchImpl)\n if (!token) return { status: \"unavailable\" }\n\n const base = config.issuer.replace(/\\/$/, \"\")\n try {\n const response = await fetchImpl(`${base}/api/machine/passwords`, {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n email: request.email.trim().toLowerCase(),\n password: request.password,\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n\n if (response.ok) {\n const body = (await response.json()) as IdentityResponse\n if (!body.identity_id) return { status: \"unavailable\" }\n return {\n status: \"provisioned\",\n identityId: body.identity_id,\n created: false,\n }\n }\n\n if (response.status === 503 || response.status >= 500) {\n return { status: \"unavailable\" }\n }\n if (response.status === 401) {\n tokenCache.delete(`${config.issuer}|${config.clientId}`)\n return { status: \"unavailable\" }\n }\n if (response.status === 404) {\n return { status: \"rejected\", reason: \"not_found\" }\n }\n const body = (await response.json().catch(() => ({}))) as {\n error?: string\n message?: string\n }\n return {\n status: \"rejected\",\n reason: body.error ?? body.message ?? `http_${response.status}`,\n }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n\nexport type DeletionOutcome =\n | { status: \"requested\"; eventId: string }\n | { status: \"rejected\"; reason: string }\n | { status: \"unavailable\" }\n\n/**\n * Asks urbangate to drop this product's role on the identity, so the person\n * stops being one of its users.\n *\n * It drops the role and nothing else. The identity belongs to the person, not\n * to the product that enrolled them: deactivating it would take away every\n * other product of the suite, without either product knowing why. urbangate\n * deletes the identity itself only once no role is left on it.\n *\n * `identityId` is the `identityId` the local user row stores, written there by\n * `provisionIdentity`. A local account that predates the move has none, and is\n * deleted locally without a call here.\n */\nexport async function requestAccountDeletion(\n config: IdentityProvisioningConfig,\n request: { identityId: string; userId?: string },\n fetchImpl: typeof fetch = fetch,\n): Promise<DeletionOutcome> {\n const token = await accessToken(config, fetchImpl)\n if (!token) return { status: \"unavailable\" }\n\n const base = config.issuer.replace(/\\/$/, \"\")\n try {\n const response = await fetchImpl(`${base}/api/v1/machine/accounts/deletions`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n // No product field: urbangate reads it from the token's client_id, so a\n // token cannot ask for an account it does not own.\n body: JSON.stringify({\n identity_id: request.identityId,\n ...(request.userId ? { user_id: request.userId } : {}),\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n\n if (response.ok) {\n const body = (await response.json()) as { event_id?: string }\n return { status: \"requested\", eventId: body.event_id ?? \"\" }\n }\n\n if (response.status >= 500) return { status: \"unavailable\" }\n if (response.status === 401) {\n tokenCache.delete(`${config.issuer}|${config.clientId}`)\n return { status: \"unavailable\" }\n }\n // The identity is already gone: an earlier attempt got through, or an\n // administrator removed it. Either way this product has no role left on\n // it, which is what the call was for.\n if (response.status === 404) return { status: \"requested\", eventId: \"\" }\n\n const body = (await response.json().catch(() => ({}))) as {\n error?: string\n message?: string\n }\n return {\n status: \"rejected\",\n reason: body.error ?? body.message ?? `http_${response.status}`,\n }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n"],"mappings":"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","names":["body"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
provisionIdentity,
|
|
3
|
+
requestAccountDeletion,
|
|
4
|
+
resetProvisioningTokenCache,
|
|
5
|
+
updateIdentityPassword
|
|
6
|
+
} from "./chunk-UURHL4NP.js";
|
|
7
|
+
export {
|
|
8
|
+
provisionIdentity,
|
|
9
|
+
requestAccountDeletion,
|
|
10
|
+
resetProvisioningTokenCache,
|
|
11
|
+
updateIdentityPassword
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=identity-provisioning-GBE6OXR3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { PlatformAuthClient } from './client.js';
|
|
|
6
6
|
export { startSso } from './client.js';
|
|
7
7
|
import * as react from 'react';
|
|
8
8
|
import { InputHTMLAttributes, ReactNode } from 'react';
|
|
9
|
-
export { C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, i as isInvitationFailure, m as mapSsoProfile } from './sso-profile-
|
|
9
|
+
export { C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, i as isInvitationFailure, m as mapSsoProfile } from './sso-profile-CJLK-hNf.js';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Returns a useSession hook bound to the given auth client.
|
package/dist/index.js
CHANGED
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { APIError, Auth, BetterAuthOptions } from 'better-auth';
|
|
2
2
|
import { o as PlatformAuthConfig } from './types-COX3VaBw.js';
|
|
3
3
|
export { t as PlatformRateLimitConfig, u as PlatformRateLimitRule, v as PlatformSession, w as PlatformSessionData, y as PlatformTwoFactorConfig, z as PlatformUser } from './types-COX3VaBw.js';
|
|
4
|
-
export { b as ClaimInvitationOptions, C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, c as claimInvitation, d as completesSignup, h as holdInviteTokenCookie, e as
|
|
4
|
+
export { b as ClaimInvitationOptions, C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, c as claimInvitation, d as completesSignup, h as holdInviteTokenCookie, e as identityIdFromIdToken, f as invitationOutcomeCookie, g as inviteTokenFrom, i as isInvitationFailure, m as mapSsoProfile, p as pinInviteToken, r as releaseInviteTokenCookie } from './sso-profile-CJLK-hNf.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The value written in place of a password hash once Kratos holds the
|
|
@@ -89,6 +89,105 @@ declare function updateIdentityPassword(config: IdentityProvisioningConfig, requ
|
|
|
89
89
|
email: string;
|
|
90
90
|
password: string;
|
|
91
91
|
}, fetchImpl?: typeof fetch): Promise<ProvisionOutcome>;
|
|
92
|
+
type DeletionOutcome = {
|
|
93
|
+
status: "requested";
|
|
94
|
+
eventId: string;
|
|
95
|
+
} | {
|
|
96
|
+
status: "rejected";
|
|
97
|
+
reason: string;
|
|
98
|
+
} | {
|
|
99
|
+
status: "unavailable";
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Asks urbangate to drop this product's role on the identity, so the person
|
|
103
|
+
* stops being one of its users.
|
|
104
|
+
*
|
|
105
|
+
* It drops the role and nothing else. The identity belongs to the person, not
|
|
106
|
+
* to the product that enrolled them: deactivating it would take away every
|
|
107
|
+
* other product of the suite, without either product knowing why. urbangate
|
|
108
|
+
* deletes the identity itself only once no role is left on it.
|
|
109
|
+
*
|
|
110
|
+
* `identityId` is the `identityId` the local user row stores, written there by
|
|
111
|
+
* `provisionIdentity`. A local account that predates the move has none, and is
|
|
112
|
+
* deleted locally without a call here.
|
|
113
|
+
*/
|
|
114
|
+
declare function requestAccountDeletion(config: IdentityProvisioningConfig, request: {
|
|
115
|
+
identityId: string;
|
|
116
|
+
userId?: string;
|
|
117
|
+
}, fetchImpl?: typeof fetch): Promise<DeletionOutcome>;
|
|
118
|
+
|
|
119
|
+
interface DeleteAccountConfig {
|
|
120
|
+
/**
|
|
121
|
+
* Ends the person's subscription. Called first and its failure is fatal:
|
|
122
|
+
* an account that is deleted but keeps being charged is far worse than a
|
|
123
|
+
* deletion its owner has to retry. Must be idempotent — no subscription is
|
|
124
|
+
* a success, not an error, because a retried deletion reaches it again.
|
|
125
|
+
*
|
|
126
|
+
* Omit on a product that bills nobody.
|
|
127
|
+
*/
|
|
128
|
+
cancelBilling?: (userId: string) => Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* Purges the product's own data: the domain tables keyed on the user, the
|
|
131
|
+
* object storage prefix, whatever else only this product knows about.
|
|
132
|
+
* Fatal, and called before anything that cannot be undone, so a failure
|
|
133
|
+
* leaves an account its owner can still sign into and delete again.
|
|
134
|
+
*
|
|
135
|
+
* Must be idempotent: `DELETE ... WHERE user_id` on already-purged rows
|
|
136
|
+
* affects none, which is what makes a retry safe.
|
|
137
|
+
*/
|
|
138
|
+
purgeDomain?: (userId: string) => Promise<void>;
|
|
139
|
+
/**
|
|
140
|
+
* Revokes what third parties still hold: OAuth grants on the person's
|
|
141
|
+
* Google or Bluesky account, API keys, webhook endpoints. Best-effort —
|
|
142
|
+
* the tokens die with the rows anyway, and a right to erasure cannot
|
|
143
|
+
* depend on another company's uptime.
|
|
144
|
+
*/
|
|
145
|
+
revokeExternal?: (userId: string) => Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* Drops this product's role at urbangate. Omit to leave the identity
|
|
148
|
+
* untouched, which is right for a product that does not enrol one.
|
|
149
|
+
*/
|
|
150
|
+
identity?: IdentityProvisioningConfig;
|
|
151
|
+
/** Reports a step that failed without stopping the deletion. */
|
|
152
|
+
onWarning?: (step: string, error: unknown) => void;
|
|
153
|
+
/**
|
|
154
|
+
* The call that asks urbangate to drop the role. Defaults to
|
|
155
|
+
* `requestAccountDeletion`; an app overrides it only in a test.
|
|
156
|
+
*/
|
|
157
|
+
requestDeletion?: (identity: IdentityProvisioningConfig, request: {
|
|
158
|
+
identityId: string;
|
|
159
|
+
userId?: string;
|
|
160
|
+
}) => Promise<DeletionOutcome>;
|
|
161
|
+
}
|
|
162
|
+
interface DeleteAccountRequest {
|
|
163
|
+
userId: string;
|
|
164
|
+
/** The `identityId` on the local user row; absent on accounts that predate urbangate. */
|
|
165
|
+
identityId?: string | null;
|
|
166
|
+
}
|
|
167
|
+
type DeleteAccountResult = {
|
|
168
|
+
status: "deleted";
|
|
169
|
+
warnings: Array<string>;
|
|
170
|
+
} | {
|
|
171
|
+
status: "failed";
|
|
172
|
+
step: DeleteAccountStep;
|
|
173
|
+
cause: unknown;
|
|
174
|
+
};
|
|
175
|
+
type DeleteAccountStep = "cancel_billing" | "purge_domain" | "drop_identity_role" | "delete_login";
|
|
176
|
+
/**
|
|
177
|
+
* Deletes one product's account, in the order that leaves the least damage
|
|
178
|
+
* when a step fails.
|
|
179
|
+
*
|
|
180
|
+
* No step can be rolled back once the next one has run, and no transaction
|
|
181
|
+
* spans a payment provider, a domain database and an identity provider. What
|
|
182
|
+
* the order buys is that a failure is always recoverable by retrying: billing
|
|
183
|
+
* stops first because a charge that outlives the account is the one outcome
|
|
184
|
+
* nobody notices, the domain data goes next while the account still exists to
|
|
185
|
+
* try again from, and the login row goes last because it is what the person
|
|
186
|
+
* would need to come back.
|
|
187
|
+
*
|
|
188
|
+
* Every step is idempotent, so the retry is safe.
|
|
189
|
+
*/
|
|
190
|
+
declare function deleteAccount(config: DeleteAccountConfig, request: DeleteAccountRequest, deleteLogin: (userId: string) => Promise<void>): Promise<DeleteAccountResult>;
|
|
92
191
|
|
|
93
192
|
/**
|
|
94
193
|
* Minimal Postgres pool contract this module needs — kept structural so the
|
|
@@ -148,4 +247,4 @@ declare class KratosSignInError extends APIError {
|
|
|
148
247
|
}
|
|
149
248
|
type PlatformAuth = ReturnType<typeof createPlatformAuth>;
|
|
150
249
|
|
|
151
|
-
export { type BootstrapAdminClient, type BootstrapAdminPool, type BootstrapFirstAdminInput, type BootstrapFirstAdminResult, type IdentityProvisioningConfig, KRATOS_SENTINEL_HASH, type KratosOutcome, KratosSignInError, type PlatformAuth, type ProvisionOutcome, bootstrapFirstAdmin, createPlatformAuth, isKratosSentinel, provisionIdentity, rememberSignInIdentifier, updateIdentityPassword };
|
|
250
|
+
export { type BootstrapAdminClient, type BootstrapAdminPool, type BootstrapFirstAdminInput, type BootstrapFirstAdminResult, type DeleteAccountConfig, type DeleteAccountRequest, type DeleteAccountResult, type DeleteAccountStep, type DeletionOutcome, type IdentityProvisioningConfig, KRATOS_SENTINEL_HASH, type KratosOutcome, KratosSignInError, type PlatformAuth, type ProvisionOutcome, bootstrapFirstAdmin, createPlatformAuth, deleteAccount, isKratosSentinel, provisionIdentity, rememberSignInIdentifier, requestAccountDeletion, updateIdentityPassword };
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
claimInvitation,
|
|
3
3
|
completesSignup,
|
|
4
4
|
holdInviteTokenCookie,
|
|
5
|
+
identityIdFromIdToken,
|
|
5
6
|
invitationOutcomeCookie,
|
|
6
7
|
inviteTokenFrom,
|
|
7
8
|
isInvitationFailure,
|
|
@@ -10,7 +11,12 @@ import {
|
|
|
10
11
|
releaseInviteTokenCookie,
|
|
11
12
|
roleFromIdToken,
|
|
12
13
|
withSignUpName
|
|
13
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-GACPOR2P.js";
|
|
15
|
+
import {
|
|
16
|
+
provisionIdentity,
|
|
17
|
+
requestAccountDeletion,
|
|
18
|
+
updateIdentityPassword
|
|
19
|
+
} from "./chunk-UURHL4NP.js";
|
|
14
20
|
|
|
15
21
|
// src/server.ts
|
|
16
22
|
import { betterAuth, APIError } from "better-auth";
|
|
@@ -97,135 +103,6 @@ async function verifyAgainstKratos(publicUrl, check, fetchImpl = fetch) {
|
|
|
97
103
|
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
// src/identity-provisioning.ts
|
|
101
|
-
var REQUEST_TIMEOUT_MS = 5e3;
|
|
102
|
-
var TOKEN_EXPIRY_MARGIN_S = 30;
|
|
103
|
-
var tokenCache = /* @__PURE__ */ new Map();
|
|
104
|
-
async function accessToken(config, fetchImpl) {
|
|
105
|
-
const key = `${config.issuer}|${config.clientId}`;
|
|
106
|
-
const cached = tokenCache.get(key);
|
|
107
|
-
const now = Date.now() / 1e3;
|
|
108
|
-
if (cached && cached.expiresAt > now) return cached.value;
|
|
109
|
-
const base = config.issuer.replace(/\/$/, "");
|
|
110
|
-
const credentials = btoa(`${config.clientId}:${config.clientSecret}`);
|
|
111
|
-
try {
|
|
112
|
-
const response = await fetchImpl(`${base}/oauth2/token`, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
headers: {
|
|
115
|
-
Authorization: `Basic ${credentials}`,
|
|
116
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
117
|
-
},
|
|
118
|
-
body: new URLSearchParams({
|
|
119
|
-
grant_type: "client_credentials",
|
|
120
|
-
audience: "urbangate",
|
|
121
|
-
scope: "urbangate:identities:provision"
|
|
122
|
-
}).toString(),
|
|
123
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
124
|
-
});
|
|
125
|
-
if (!response.ok) return void 0;
|
|
126
|
-
const body = await response.json();
|
|
127
|
-
if (!body.access_token) return void 0;
|
|
128
|
-
tokenCache.set(key, {
|
|
129
|
-
value: body.access_token,
|
|
130
|
-
expiresAt: now + (body.expires_in ?? 900) - TOKEN_EXPIRY_MARGIN_S
|
|
131
|
-
});
|
|
132
|
-
return body.access_token;
|
|
133
|
-
} catch {
|
|
134
|
-
return void 0;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
async function provisionIdentity(config, request, fetchImpl = fetch) {
|
|
138
|
-
const token = await accessToken(config, fetchImpl);
|
|
139
|
-
if (!token) return { status: "unavailable" };
|
|
140
|
-
const base = config.issuer.replace(/\/$/, "");
|
|
141
|
-
try {
|
|
142
|
-
const response = await fetchImpl(`${base}/api/machine/identities`, {
|
|
143
|
-
method: "POST",
|
|
144
|
-
headers: {
|
|
145
|
-
Authorization: `Bearer ${token}`,
|
|
146
|
-
"Content-Type": "application/json"
|
|
147
|
-
},
|
|
148
|
-
body: JSON.stringify({
|
|
149
|
-
email: request.email.trim().toLowerCase(),
|
|
150
|
-
email_verified: true,
|
|
151
|
-
name: request.name ?? "",
|
|
152
|
-
role: config.role,
|
|
153
|
-
product: config.product,
|
|
154
|
-
...request.password ? { password: request.password } : {}
|
|
155
|
-
}),
|
|
156
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
157
|
-
});
|
|
158
|
-
if (response.ok) {
|
|
159
|
-
const body2 = await response.json();
|
|
160
|
-
if (!body2.identity_id) return { status: "unavailable" };
|
|
161
|
-
return {
|
|
162
|
-
status: "provisioned",
|
|
163
|
-
identityId: body2.identity_id,
|
|
164
|
-
created: body2.created === true
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
if (response.status === 503 || response.status >= 500) {
|
|
168
|
-
return { status: "unavailable" };
|
|
169
|
-
}
|
|
170
|
-
if (response.status === 401) {
|
|
171
|
-
tokenCache.delete(`${config.issuer}|${config.clientId}`);
|
|
172
|
-
return { status: "unavailable" };
|
|
173
|
-
}
|
|
174
|
-
const body = await response.json().catch(() => ({}));
|
|
175
|
-
return {
|
|
176
|
-
status: "rejected",
|
|
177
|
-
reason: body.error ?? body.message ?? `http_${response.status}`
|
|
178
|
-
};
|
|
179
|
-
} catch {
|
|
180
|
-
return { status: "unavailable" };
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
async function updateIdentityPassword(config, request, fetchImpl = fetch) {
|
|
184
|
-
const token = await accessToken(config, fetchImpl);
|
|
185
|
-
if (!token) return { status: "unavailable" };
|
|
186
|
-
const base = config.issuer.replace(/\/$/, "");
|
|
187
|
-
try {
|
|
188
|
-
const response = await fetchImpl(`${base}/api/machine/passwords`, {
|
|
189
|
-
method: "PUT",
|
|
190
|
-
headers: {
|
|
191
|
-
Authorization: `Bearer ${token}`,
|
|
192
|
-
"Content-Type": "application/json"
|
|
193
|
-
},
|
|
194
|
-
body: JSON.stringify({
|
|
195
|
-
email: request.email.trim().toLowerCase(),
|
|
196
|
-
password: request.password
|
|
197
|
-
}),
|
|
198
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
199
|
-
});
|
|
200
|
-
if (response.ok) {
|
|
201
|
-
const body2 = await response.json();
|
|
202
|
-
if (!body2.identity_id) return { status: "unavailable" };
|
|
203
|
-
return {
|
|
204
|
-
status: "provisioned",
|
|
205
|
-
identityId: body2.identity_id,
|
|
206
|
-
created: false
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
if (response.status === 503 || response.status >= 500) {
|
|
210
|
-
return { status: "unavailable" };
|
|
211
|
-
}
|
|
212
|
-
if (response.status === 401) {
|
|
213
|
-
tokenCache.delete(`${config.issuer}|${config.clientId}`);
|
|
214
|
-
return { status: "unavailable" };
|
|
215
|
-
}
|
|
216
|
-
if (response.status === 404) {
|
|
217
|
-
return { status: "rejected", reason: "not_found" };
|
|
218
|
-
}
|
|
219
|
-
const body = await response.json().catch(() => ({}));
|
|
220
|
-
return {
|
|
221
|
-
status: "rejected",
|
|
222
|
-
reason: body.error ?? body.message ?? `http_${response.status}`
|
|
223
|
-
};
|
|
224
|
-
} catch {
|
|
225
|
-
return { status: "unavailable" };
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
106
|
// src/google-defaults.ts
|
|
230
107
|
function withGoogleDefaults(google) {
|
|
231
108
|
if (typeof google === "function") return google;
|
|
@@ -274,6 +151,59 @@ function resolveRateLimit(config) {
|
|
|
274
151
|
};
|
|
275
152
|
}
|
|
276
153
|
|
|
154
|
+
// src/delete-account.ts
|
|
155
|
+
async function deleteAccount(config, request, deleteLogin) {
|
|
156
|
+
const warnings = [];
|
|
157
|
+
const warn = (step, error) => {
|
|
158
|
+
warnings.push(step);
|
|
159
|
+
config.onWarning?.(step, error);
|
|
160
|
+
};
|
|
161
|
+
if (config.cancelBilling) {
|
|
162
|
+
try {
|
|
163
|
+
await config.cancelBilling(request.userId);
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
return { status: "failed", step: "cancel_billing", cause };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (config.purgeDomain) {
|
|
169
|
+
try {
|
|
170
|
+
await config.purgeDomain(request.userId);
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
return { status: "failed", step: "purge_domain", cause };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (config.revokeExternal) {
|
|
176
|
+
try {
|
|
177
|
+
await config.revokeExternal(request.userId);
|
|
178
|
+
} catch (cause) {
|
|
179
|
+
warn("revoke_external", cause);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (config.identity && request.identityId) {
|
|
183
|
+
const call = config.requestDeletion ?? (await import("./identity-provisioning-GBE6OXR3.js")).requestAccountDeletion;
|
|
184
|
+
const outcome = await call(config.identity, {
|
|
185
|
+
identityId: request.identityId,
|
|
186
|
+
userId: request.userId
|
|
187
|
+
});
|
|
188
|
+
if (outcome.status === "unavailable") {
|
|
189
|
+
return {
|
|
190
|
+
status: "failed",
|
|
191
|
+
step: "drop_identity_role",
|
|
192
|
+
cause: new Error("urbangate unavailable")
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (outcome.status === "rejected") {
|
|
196
|
+
warn("drop_identity_role", new Error(outcome.reason));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await deleteLogin(request.userId);
|
|
201
|
+
} catch (cause) {
|
|
202
|
+
return { status: "failed", step: "delete_login", cause };
|
|
203
|
+
}
|
|
204
|
+
return { status: "deleted", warnings };
|
|
205
|
+
}
|
|
206
|
+
|
|
277
207
|
// src/bootstrap-admin.ts
|
|
278
208
|
async function bootstrapFirstAdmin(auth, pool, input) {
|
|
279
209
|
const client = await pool.connect();
|
|
@@ -386,7 +316,10 @@ function createPlatformAuth(config) {
|
|
|
386
316
|
account: {
|
|
387
317
|
accountLinking: sso ? { enabled: true, trustedProviders: [ssoProviderId] } : { enabled: false }
|
|
388
318
|
},
|
|
389
|
-
|
|
319
|
+
// Declared for single sign-on too, not only for Kratos passwords: the
|
|
320
|
+
// suite addresses a person by their provider identity id, and an app key
|
|
321
|
+
// is issued against it, so a row without one cannot ask for a key.
|
|
322
|
+
...kratosPasswords || sso ? {
|
|
390
323
|
user: {
|
|
391
324
|
additionalFields: {
|
|
392
325
|
identityId: {
|
|
@@ -583,8 +516,13 @@ function withSsoRoleSync(hooks, sso, providerId) {
|
|
|
583
516
|
const sync = async (account, ctx) => {
|
|
584
517
|
if (account.providerId !== providerId || !ctx) return;
|
|
585
518
|
const role = roleFromIdToken(account.idToken, sso.adminRole);
|
|
586
|
-
|
|
587
|
-
|
|
519
|
+
const identityId = identityIdFromIdToken(account.idToken);
|
|
520
|
+
const update = {
|
|
521
|
+
...role ? { role } : {},
|
|
522
|
+
...identityId ? { identityId } : {}
|
|
523
|
+
};
|
|
524
|
+
if (Object.keys(update).length === 0) return;
|
|
525
|
+
await ctx.context.internalAdapter.updateUser(account.userId, update);
|
|
588
526
|
};
|
|
589
527
|
const chain = (own) => async (account, ctx) => {
|
|
590
528
|
await own?.(account, ctx);
|
|
@@ -691,7 +629,9 @@ export {
|
|
|
691
629
|
claimInvitation,
|
|
692
630
|
completesSignup,
|
|
693
631
|
createPlatformAuth,
|
|
632
|
+
deleteAccount,
|
|
694
633
|
holdInviteTokenCookie,
|
|
634
|
+
identityIdFromIdToken,
|
|
695
635
|
invitationOutcomeCookie,
|
|
696
636
|
inviteTokenFrom,
|
|
697
637
|
isInvitationFailure,
|
|
@@ -701,6 +641,7 @@ export {
|
|
|
701
641
|
provisionIdentity,
|
|
702
642
|
releaseInviteTokenCookie,
|
|
703
643
|
rememberSignInIdentifier,
|
|
644
|
+
requestAccountDeletion,
|
|
704
645
|
updateIdentityPassword
|
|
705
646
|
};
|
|
706
647
|
//# sourceMappingURL=server.js.map
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts","../src/kratos-credentials.ts","../src/identity-provisioning.ts","../src/google-defaults.ts","../src/sso-endpoints.ts","../src/rate-limit.ts","../src/bootstrap-admin.ts"],"sourcesContent":["import { betterAuth, APIError, type Auth, type BetterAuthOptions } from \"better-auth\"\nimport { emailOTP, admin, magicLink, twoFactor, genericOAuth } from \"better-auth/plugins\"\nimport type {\n PlatformAuthConfig,\n PlatformAuthMailerType,\n PlatformKratosPasswordConfig,\n PlatformSsoConfig,\n} from \"./types\"\nimport {\n KRATOS_SENTINEL_HASH,\n verifyAgainstKratos,\n type KratosOutcome,\n} from \"./kratos-credentials\"\nimport { provisionIdentity } from \"./identity-provisioning\"\nimport { withGoogleDefaults } from \"./google-defaults\"\nimport { mapSsoProfile, roleFromIdToken, type SsoProfile } from \"./sso-profile\"\nimport { ssoEndpoints } from \"./sso-endpoints\"\nimport { withSignUpName } from \"./signup-name\"\nimport { resolveRateLimit } from \"./rate-limit\"\n\nconst DEFAULT_EMAIL_SUBJECTS: Record<string, string> = {\n \"email-verification\": \"Verify your account\",\n \"forget-password\": \"Reset your password\",\n \"sign-in\": \"Your sign-in code\",\n}\n\nfunction defaultRenderOtpEmail(otp: string): string {\n return `\n <div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px\">\n <h2 style=\"font-size:20px;font-weight:600;margin-bottom:16px\">Your verification code</h2>\n <p style=\"color:#555;margin-bottom:24px\">Use the code below to continue. It expires in 5 minutes.</p>\n <div style=\"background:#f5f5f5;border-radius:8px;padding:24px;text-align:center;letter-spacing:8px;font-size:32px;font-weight:700\">\n ${otp}\n </div>\n <p style=\"color:#999;font-size:12px;margin-top:24px\">If you didn't request this, you can safely ignore this email.</p>\n </div>\n `\n}\n\nfunction defaultRenderMagicLinkEmail(url: string): string {\n return `\n <div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px\">\n <h2 style=\"font-size:20px;font-weight:600;margin-bottom:16px\">Your sign-in link</h2>\n <p style=\"color:#555;margin-bottom:24px\">Click the button below to sign in. The link expires in 5 minutes and works once.</p>\n <a href=\"${url}\" style=\"display:inline-block;background:#111;color:#fff;text-decoration:none;border-radius:8px;padding:14px 28px;font-weight:600\">Sign in</a>\n <p style=\"color:#999;font-size:12px;margin-top:24px;word-break:break-all\">Or paste this address into your browser:<br>${url}</p>\n <p style=\"color:#999;font-size:12px;margin-top:16px\">If you didn't request this, you can safely ignore this email.</p>\n </div>\n `\n}\n\n/**\n * Creates a Better Auth instance with platform defaults.\n * Each app calls this with its own config (DB, secret, providers, plugins).\n */\nexport function createPlatformAuth(\n config: PlatformAuthConfig,\n): Auth<BetterAuthOptions> {\n const {\n database,\n baseURL,\n secret,\n appName,\n mailer,\n google,\n github,\n plugins = [],\n databaseHooks,\n betaMode = false,\n isInvited,\n emailSubjects,\n renderOtpEmail,\n magicLink: magicLinkConfig,\n rateLimit,\n twoFactor: twoFactorConfig,\n trustedOrigins,\n sso,\n kratosPasswords,\n } = config\n const ssoProviderId = sso?.providerId ?? \"urbangate\"\n\n const subjects = { ...DEFAULT_EMAIL_SUBJECTS, ...emailSubjects }\n const renderEmail = renderOtpEmail ?? defaultRenderOtpEmail\n\n // The concrete instance type (with email-otp/admin plugins) is widened to\n // the base Auth type so the published .d.ts stays portable (inferring the\n // full plugin type triggers TS2742 — it can't be named without a zod ref).\n // The admin() plugin's user.role field is re-exposed via module augmentation\n // below, so consumers (e.g. transcript-web me.ts) still see session.user.role.\n return betterAuth({\n database,\n baseURL,\n secret,\n ...(trustedOrigins ? { trustedOrigins } : {}),\n rateLimit: resolveRateLimit(rateLimit),\n emailAndPassword: {\n enabled: true,\n requireEmailVerification: true,\n ...(kratosPasswords\n ? {\n password: {\n // Sign-in refuses before reaching the verifier when the account\n // carries no hash, so a sign-up must still write one. It is a\n // constant that validates nothing, never a hash of the password.\n hash: kratosHasher(kratosPasswords),\n verify: kratosVerifier(kratosPasswords),\n },\n }\n : {}),\n },\n // Never auto-merge a social identity into an existing account by matching\n // email. Better Auth links by default (email-verified providers are trusted),\n // so signing in with Google/GitHub on an email already registered would fold\n // that identity into the existing account. We keep each sign-in method its\n // own account: a social login on a taken email is refused, not linked.\n // The suite's own identity provider is the one exception: it verifies\n // emails itself, and the same person must land on the same account\n // whether they signed in here before the SSO existed or not.\n account: {\n accountLinking: sso\n ? { enabled: true, trustedProviders: [ssoProviderId] }\n : { enabled: false },\n },\n ...(kratosPasswords\n ? {\n user: {\n additionalFields: {\n identityId: {\n type: \"string\",\n required: false,\n input: false,\n },\n },\n },\n }\n : {}),\n // Naming happens here rather than on /sign-up/email so that every way in\n // is covered: a magic link that signs up bypasses the endpoint entirely\n // and calls createUser straight, with `name: name || \"\"`.\n databaseHooks: {\n ...databaseHooks,\n account: withSsoRoleSync(databaseHooks?.account, sso, ssoProviderId),\n user: {\n ...databaseHooks?.user,\n update: {\n ...databaseHooks?.user?.update,\n // A password sign-up is created unverified and confirmed by its OTP\n // a moment later; a social sign-up may be confirmed by the provider\n // later still. Enrolment follows the address becoming verified,\n // whenever that happens, and is idempotent so it never doubles.\n after: withIdentityProvisioning(\n databaseHooks?.user?.update?.after,\n kratosPasswords,\n ),\n },\n create: {\n ...databaseHooks?.user?.create,\n after: withIdentityProvisioning(\n databaseHooks?.user?.create?.after,\n kratosPasswords,\n ),\n before: async (user: Record<string, unknown>, ctx: unknown) => {\n const named = withSignUpName(user)\n const appHook = databaseHooks?.user?.create?.before\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const applied = await appHook?.(named as any, ctx as any)\n if (applied === false) return false\n if (applied && typeof applied === \"object\" && \"data\" in applied) {\n return { data: withSignUpName(applied.data) }\n }\n return { data: named }\n },\n },\n },\n },\n hooks: {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n before: async (ctx: any) => {\n if (kratosPasswords) {\n const body = ctx.body as\n | { email?: string; password?: string; newPassword?: string }\n | undefined\n if (ctx.path === \"/sign-in/email\" && body?.email && body?.password) {\n rememberSignInIdentifier(body.password, body.email)\n }\n // Sign-up and the OTP reset name the address they act on; a change\n // of password only has the session, whose user carries it.\n if (\n (ctx.path === \"/sign-up/email\" ||\n ctx.path === \"/email-otp/reset-password\") &&\n body?.email &&\n body?.password\n ) {\n rememberPasswordOwner(body.password, body.email)\n }\n if (ctx.path === \"/change-password\" && body?.newPassword) {\n const email = sessionEmail(ctx)\n if (email) rememberPasswordOwner(body.newPassword, email)\n }\n }\n if (!betaMode) return\n if (ctx.path !== \"/sign-up/email\") return\n const body = ctx.body as { email?: string; inviteToken?: string } | undefined\n const email = body?.email\n const inviteToken = body?.inviteToken\n if (email && inviteToken && isInvited) {\n const ok = await isInvited(email, inviteToken)\n if (ok) return\n }\n throw new APIError(\"FORBIDDEN\", {\n message: \"Registration is invite-only during the private beta.\",\n })\n },\n },\n plugins: [\n emailOTP({\n async sendVerificationOTP({ email, otp, type }) {\n const subject = subjects[type]\n ? `${subjects[type]} - ${appName}`\n : `Your ${appName} code`\n const html = renderEmail(otp, type as PlatformAuthMailerType)\n\n if (mailer) {\n await mailer({\n to: email,\n subject,\n html,\n type: type as PlatformAuthMailerType,\n otp,\n })\n return\n }\n\n console.warn(\n `[EMAIL] No mailer configured — logging OTP to stdout for ${email} (${type}): ${otp}`,\n )\n },\n otpLength: 6,\n expiresIn: 300,\n overrideDefaultEmailVerification: true,\n }),\n ...(magicLinkConfig\n ? [\n magicLink({\n expiresIn: magicLinkConfig.expiresIn ?? 300,\n // A magic link that signs up walks past both gates the platform\n // puts on the front door: requireEmailVerification, and the\n // invite-only hook, which only guards /sign-up/email.\n disableSignUp: !magicLinkConfig.allowSignUp,\n async sendMagicLink({ email, url }) {\n const subject =\n magicLinkConfig.subject ?? `Your sign-in link - ${appName}`\n const html = (\n magicLinkConfig.render ?? defaultRenderMagicLinkEmail\n )(url, email)\n\n if (mailer) {\n await mailer({\n to: email,\n subject,\n html,\n type: \"magic-link\",\n url,\n })\n return\n }\n\n console.warn(\n `[EMAIL] No mailer configured — logging magic link to stdout for ${email}: ${url}`,\n )\n },\n }),\n ]\n : []),\n admin(),\n ...(sso\n ? [\n genericOAuth({\n config: [\n {\n providerId: ssoProviderId,\n ...ssoEndpoints(sso.issuer),\n clientId: sso.clientId,\n clientSecret: sso.clientSecret,\n scopes: [\"openid\", \"email\", \"profile\", \"offline_access\"],\n pkce: true,\n overrideUserInfo: true,\n disableSignUp: sso.allowSignUp === false,\n mapProfileToUser: (profile) =>\n mapSsoProfile(profile as SsoProfile, sso.adminRole),\n },\n ],\n }),\n ]\n : []),\n ...(twoFactorConfig?.enabled\n ? [\n twoFactor({\n issuer: twoFactorConfig.issuer ?? appName,\n skipVerificationOnEnable:\n twoFactorConfig.skipVerificationOnEnable ?? false,\n }),\n ]\n : []),\n ...plugins, // app-specific plugins (e.g. tanstackStartCookies)\n ],\n socialProviders: {\n // Spread as given rather than rebuilt field by field: anything Better\n // Auth accepts belongs to the app, and a config silently dropped on the\n // way through is how an app ends up writing a plugin to put it back.\n //\n // The two defaults below are the platform's, not Google's: without\n // accessType 'offline' Google never mints a refresh token, and without\n // 'consent' it stops minting one for an account that already consented.\n // A NULL refreshToken means deleting an account can revoke the access\n // token but cannot remove the app from myaccount.google.com/permissions,\n // so the grant outlives the account it belonged to.\n ...(google ? { google: withGoogleDefaults(google) } : {}),\n ...(github ? { github } : {}),\n },\n }) as unknown as Auth<BetterAuthOptions>\n}\n\ntype UserHooks = NonNullable<NonNullable<BetterAuthOptions[\"databaseHooks\"]>[\"user\"]>\ntype UserAfterHook = NonNullable<NonNullable<UserHooks[\"create\"]>[\"after\"]>\n\n/**\n * Gives the new local user an identity at the provider and stores its id.\n *\n * This runs after the insert commits, so it cannot be atomic with the\n * sign-up: a provider that is down leaves `identityId` null and the person\n * registered all the same. The endpoint is idempotent on the address, so the\n * repair re-sends without risking a second identity.\n */\nfunction withIdentityProvisioning(\n own: UserAfterHook | undefined,\n config: PlatformKratosPasswordConfig | undefined,\n): UserAfterHook | undefined {\n if (!config) return own\n return async (user, ctx) => {\n await own?.(user, ctx)\n const record = user as {\n id?: string\n email?: string\n name?: string\n emailVerified?: boolean\n identityId?: unknown\n }\n if (!record.id || !record.email) return\n // Already enrolled: every later update of the row would otherwise call the\n // provider again for nothing.\n if (typeof record.identityId === \"string\" && record.identityId) return\n // An address nobody proved belongs to this person must not reach the\n // provider: enrolment is idempotent on the address, so an unverified one\n // would join them to the identity of whoever actually owns it. A social\n // sign-up whose provider reports the address unverified, and a password\n // sign-up before its OTP, are enrolled once the address is confirmed.\n if (record.emailVerified !== true) return\n\n const outcome = await provisionIdentity(config, {\n email: record.email,\n name: record.name,\n })\n\n if (outcome.status === \"provisioned\" && ctx) {\n await ctx.context.internalAdapter.updateUser(record.id, {\n identityId: outcome.identityId,\n })\n return\n }\n\n await config.onProvisioningDeferred?.({ userId: record.id, email: record.email })\n }\n}\n\ntype AccountHooks = NonNullable<NonNullable<BetterAuthOptions[\"databaseHooks\"]>[\"account\"]>\ntype AccountAfterHook = NonNullable<NonNullable<AccountHooks[\"create\"]>[\"after\"]>\n\n// The admin plugin declares `role` as not settable from input, so the role\n// mapProfileToUser returns is dropped when the OAuth path creates the user.\n// The account row, created then refreshed on every sign-in, carries the ID\n// token: its roles claim is what sets the local role, each time.\nfunction withSsoRoleSync(\n hooks: AccountHooks | undefined,\n sso: PlatformSsoConfig | undefined,\n providerId: string,\n): AccountHooks | undefined {\n if (!sso) return hooks\n const sync: AccountAfterHook = async (account, ctx) => {\n if (account.providerId !== providerId || !ctx) return\n const role = roleFromIdToken(account.idToken, sso.adminRole)\n if (!role) return\n await ctx.context.internalAdapter.updateUser(account.userId, { role })\n }\n const chain =\n (own: AccountAfterHook | undefined): AccountAfterHook =>\n async (account, ctx) => {\n await own?.(account, ctx)\n await sync(account, ctx)\n }\n return {\n ...hooks,\n create: { ...hooks?.create, after: chain(hooks?.create?.after) },\n update: { ...hooks?.update, after: chain(hooks?.update?.after) },\n }\n}\n\n/**\n * Better Auth's verifier is handed the stored hash and the submitted password,\n * never the address, and the same verifier serves sign-in, password change and\n * account deletion. The address of the sign-in being processed is carried here\n * by the route hook, keyed by the submitted password so two concurrent\n * sign-ins cannot read each other's.\n */\nconst pendingIdentifiers = new Map<string, string>()\n\nexport function rememberSignInIdentifier(password: string, email: string): void {\n pendingIdentifiers.set(password, email.trim().toLowerCase())\n}\n\nfunction takeSignInIdentifier(password: string): string | undefined {\n const email = pendingIdentifiers.get(password)\n pendingIdentifiers.delete(password)\n return email\n}\n\n// The same blind spot on the writing side: `hash` is handed the new password\n// and nothing else, and it is the only place Better Auth exposes it before\n// storing a placeholder in its stead. Sign-up and reset carry the address in\n// their body; a password change carries only a session, so the route hook\n// resolves it there.\nconst pendingPasswordOwners = new Map<string, string>()\n\nfunction rememberPasswordOwner(password: string, email: string): void {\n pendingPasswordOwners.set(password, email.trim().toLowerCase())\n}\n\nfunction takePasswordOwner(password: string): string | undefined {\n const email = pendingPasswordOwners.get(password)\n pendingPasswordOwners.delete(password)\n return email\n}\n\n// Writing a password is the one operation that must reach the provider: a\n// placeholder stored against a password Kratos never received is an account\n// its owner can never open. It is relayed before the local write, so a\n// provider that refuses it fails the request instead of stranding the account.\nfunction kratosHasher(config: PlatformKratosPasswordConfig) {\n return async (password: string): Promise<string> => {\n const email = takePasswordOwner(password)\n if (!email) return KRATOS_SENTINEL_HASH\n\n const outcome = await provisionIdentity(config, { email, password })\n if (outcome.status === \"provisioned\") return KRATOS_SENTINEL_HASH\n\n if (outcome.status === \"unavailable\") {\n throw new KratosSignInError(\n \"SERVICE_UNAVAILABLE\",\n \"IDENTITY_PROVIDER_UNAVAILABLE\",\n \"The identity service is unavailable. Nothing was changed — try again shortly.\",\n )\n }\n throw new KratosSignInError(\n \"UNPROCESSABLE_ENTITY\",\n \"IDENTITY_PASSWORD_REFUSED\",\n \"The identity service refused this password.\",\n )\n }\n}\n\nexport class KratosSignInError extends APIError {\n constructor(\n status:\n | \"UNAUTHORIZED\"\n | \"FORBIDDEN\"\n | \"SERVICE_UNAVAILABLE\"\n | \"UNPROCESSABLE_ENTITY\",\n code: string,\n message: string,\n ) {\n super(status, { code, message })\n }\n}\n\nfunction refusalFor(outcome: KratosOutcome): KratosSignInError | undefined {\n switch (outcome.status) {\n case \"no_credential\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"IDENTITY_HAS_NO_PASSWORD\",\n \"This account has no password yet at the identity provider. Use the password recovery to set one.\",\n )\n case \"second_factor_required\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"SECOND_FACTOR_REQUIRED\",\n \"A second factor is required to sign in.\",\n )\n case \"account_disabled\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"ACCOUNT_DISABLED\",\n \"This account is deactivated.\",\n )\n case \"unavailable\":\n return new KratosSignInError(\n \"SERVICE_UNAVAILABLE\",\n \"IDENTITY_PROVIDER_UNAVAILABLE\",\n \"The identity service is unavailable. Your password has not been refused — try again shortly.\",\n )\n default:\n return undefined\n }\n}\n\n/**\n * Fails closed: anything other than an explicit success refuses the sign-in,\n * and only an explicit refusal by Kratos reads as a wrong password. An outage\n * answers 503, so nobody is told their password is wrong and rotates a\n * password that was right.\n */\nfunction kratosVerifier(config: PlatformKratosPasswordConfig) {\n return async ({ password }: { hash: string; password: string }): Promise<boolean> => {\n const email = takeSignInIdentifier(password)\n if (!email) return false\n const outcome = await verifyAgainstKratos(config.publicUrl, { email, password })\n if (outcome.status === \"valid\") return true\n const refusal = refusalFor(outcome)\n if (refusal) throw refusal\n return false\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction sessionEmail(ctx: any): string | undefined {\n const email = ctx?.context?.session?.user?.email\n return typeof email === \"string\" ? email : undefined\n}\n\nexport type PlatformAuth = ReturnType<typeof createPlatformAuth>\n\n// Re-export the session contract from /server so consumers that import the\n// auth factory can type api.getSession() without a second import path.\nexport type {\n PlatformUser,\n PlatformSession,\n PlatformSessionData,\n PlatformRateLimitConfig,\n PlatformRateLimitRule,\n PlatformTwoFactorConfig,\n} from \"./types\"\n\n// Invitation claiming runs on the auth callback, where the session is\n// established — the one place every sign-up flow passes through.\nexport {\n claimInvitation,\n completesSignup,\n holdInviteTokenCookie,\n invitationOutcomeCookie,\n inviteTokenFrom,\n isInvitationFailure,\n pinInviteToken,\n releaseInviteTokenCookie,\n} from \"./invitation\"\nexport type { ClaimOutcome, ClaimInvitationOptions } from \"./invitation\"\n\nexport { mapSsoProfile } from \"./sso-profile\"\nexport type { SsoProfile, SsoMappedUser } from \"./sso-profile\"\n\nexport { KRATOS_SENTINEL_HASH, isKratosSentinel } from \"./kratos-credentials\"\nexport type { KratosOutcome } from \"./kratos-credentials\"\n\n// The repair path an app schedules for the sign-ups whose provisioning could\n// not reach the provider.\nexport { provisionIdentity, updateIdentityPassword } from \"./identity-provisioning\"\nexport type {\n IdentityProvisioningConfig,\n ProvisionOutcome,\n} from \"./identity-provisioning\"\n\nexport { bootstrapFirstAdmin } from \"./bootstrap-admin\"\nexport type {\n BootstrapAdminPool,\n BootstrapAdminClient,\n BootstrapFirstAdminInput,\n BootstrapFirstAdminResult,\n} from \"./bootstrap-admin\"\n","/**\n * The value written in place of a password hash once Kratos holds the\n * password. Better Auth's sign-in route refuses the request before reaching\n * the verifier when the credential account carries no hash, so a row must\n * exist and be non-empty for the Kratos check to run at all.\n *\n * It is not a hash and cannot become one: argon2/bcrypt/scrypt verification\n * of this string fails on its format, so a build that ever bypassed the\n * custom verifier refuses everyone instead of admitting anyone.\n */\nexport const KRATOS_SENTINEL_HASH = \"kratos:external-credential:no-local-hash\"\n\nexport function isKratosSentinel(hash: string | null | undefined): boolean {\n return hash === KRATOS_SENTINEL_HASH\n}\n\nexport type KratosOutcome =\n | { status: \"valid\"; identityId: string }\n | { status: \"invalid_credentials\" }\n | { status: \"no_credential\" }\n | { status: \"email_not_verified\" }\n | { status: \"second_factor_required\" }\n | { status: \"account_disabled\" }\n | { status: \"unavailable\" }\n\nexport interface KratosCredentialCheck {\n email: string\n password: string\n}\n\ninterface KratosLoginFlow {\n id: string\n ui?: { nodes?: Array<{ attributes?: { name?: string; value?: string } }> }\n}\n\ninterface KratosErrorBody {\n error?: { id?: string; code?: number; reason?: string; message?: string }\n ui?: { messages?: Array<{ id?: number; text?: string; type?: string }> }\n redirect_browser_to?: string\n}\n\ninterface KratosSuccessBody {\n session?: { identity?: { id?: string; state?: string } }\n session_token?: string\n}\n\nconst FLOW_TIMEOUT_MS = 5000\n\nfunction csrfTokenOf(flow: KratosLoginFlow): string | undefined {\n return flow.ui?.nodes?.find((n) => n.attributes?.name === \"csrf_token\")\n ?.attributes?.value\n}\n\n/**\n * Kratos reports a refusal through numbered UI messages rather than a status\n * code of its own; 4000006 is the generic credential refusal, 4000010 an\n * inactive account, 4000002 a missing field. The address-unverified and\n * second-factor cases arrive as a redirect or an aal2 requirement instead.\n */\nfunction outcomeFromMessages(body: KratosErrorBody): KratosOutcome | undefined {\n const ids = (body.ui?.messages ?? []).map((m) => m.id)\n if (ids.includes(4000010)) return { status: \"account_disabled\" }\n if (ids.includes(4000006) || ids.includes(4000002)) {\n return { status: \"invalid_credentials\" }\n }\n const errorId = body.error?.id\n if (errorId === \"session_aal2_required\") {\n return { status: \"second_factor_required\" }\n }\n if (errorId === \"session_verified_address_required\") {\n return { status: \"email_not_verified\" }\n }\n if (errorId === \"browser_location_change_required\") {\n return { status: \"second_factor_required\" }\n }\n return undefined\n}\n\n/**\n * Validates a password against Kratos through the native (API) login flow,\n * which returns the outcome as JSON instead of driving a browser.\n *\n * Every failure that is not an explicit refusal by Kratos answers\n * `unavailable`, never `invalid_credentials`: a person told their password is\n * wrong during an outage changes a password that was right.\n */\nexport async function verifyAgainstKratos(\n publicUrl: string,\n check: KratosCredentialCheck,\n fetchImpl: typeof fetch = fetch,\n): Promise<KratosOutcome> {\n const base = publicUrl.replace(/\\/$/, \"\")\n let flow: KratosLoginFlow\n try {\n const started = await fetchImpl(`${base}/self-service/login/api`, {\n method: \"GET\",\n headers: { Accept: \"application/json\" },\n signal: AbortSignal.timeout(FLOW_TIMEOUT_MS),\n })\n if (!started.ok) return { status: \"unavailable\" }\n flow = (await started.json()) as KratosLoginFlow\n if (!flow?.id) return { status: \"unavailable\" }\n } catch {\n return { status: \"unavailable\" }\n }\n\n const csrf = csrfTokenOf(flow)\n try {\n const submitted = await fetchImpl(\n `${base}/self-service/login?flow=${encodeURIComponent(flow.id)}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Accept: \"application/json\" },\n body: JSON.stringify({\n method: \"password\",\n identifier: check.email.trim().toLowerCase(),\n password: check.password,\n ...(csrf ? { csrf_token: csrf } : {}),\n }),\n signal: AbortSignal.timeout(FLOW_TIMEOUT_MS),\n },\n )\n\n if (submitted.ok) {\n const body = (await submitted.json()) as KratosSuccessBody\n const identity = body.session?.identity\n if (!identity?.id) return { status: \"unavailable\" }\n if (identity.state && identity.state !== \"active\") {\n return { status: \"account_disabled\" }\n }\n return { status: \"valid\", identityId: identity.id }\n }\n\n if (submitted.status >= 500) return { status: \"unavailable\" }\n\n const body = (await submitted.json().catch(() => ({}))) as KratosErrorBody\n const mapped = outcomeFromMessages(body)\n if (mapped) return mapped\n if (submitted.status === 400 || submitted.status === 401) {\n return { status: \"invalid_credentials\" }\n }\n if (submitted.status === 403) return { status: \"second_factor_required\" }\n return { status: \"unavailable\" }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n","export interface IdentityProvisioningConfig {\n /** urbangate's issuer URL, e.g. https://id.urbangate.dev */\n issuer: string\n /** The product's provisioner client, e.g. \"spore-provisioner\". */\n clientId: string\n clientSecret: string\n /** The role granted on provisioning, e.g. \"spore:user\". */\n role: string\n /** The product id carried in the request, e.g. \"spore\". */\n product: string\n}\n\nexport type ProvisionOutcome =\n | { status: \"provisioned\"; identityId: string; created: boolean }\n | { status: \"rejected\"; reason: string }\n | { status: \"unavailable\" }\n\nexport interface ProvisionRequest {\n email: string\n name?: string\n /**\n * The password the person just typed on the product's own form. Kratos\n * hashes it with the hasher its configuration declares, so an app cannot\n * hand over one it hashed itself; it is relayed for the length of this\n * request and stored nowhere. Omitted, the identity is created without a\n * credential and its owner sets one through recovery.\n */\n password?: string\n}\n\ninterface TokenResponse {\n access_token?: string\n expires_in?: number\n}\n\ninterface IdentityResponse {\n identity_id?: string\n created?: boolean\n}\n\nconst REQUEST_TIMEOUT_MS = 5000\nconst TOKEN_EXPIRY_MARGIN_S = 30\n\ninterface CachedToken {\n value: string\n expiresAt: number\n}\n\nconst tokenCache = new Map<string, CachedToken>()\n\nexport function resetProvisioningTokenCache(): void {\n tokenCache.clear()\n}\n\nasync function accessToken(\n config: IdentityProvisioningConfig,\n fetchImpl: typeof fetch,\n): Promise<string | undefined> {\n const key = `${config.issuer}|${config.clientId}`\n const cached = tokenCache.get(key)\n const now = Date.now() / 1000\n if (cached && cached.expiresAt > now) return cached.value\n\n const base = config.issuer.replace(/\\/$/, \"\")\n const credentials = btoa(`${config.clientId}:${config.clientSecret}`)\n try {\n const response = await fetchImpl(`${base}/oauth2/token`, {\n method: \"POST\",\n headers: {\n Authorization: `Basic ${credentials}`,\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n audience: \"urbangate\",\n scope: \"urbangate:identities:provision\",\n }).toString(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n if (!response.ok) return undefined\n const body = (await response.json()) as TokenResponse\n if (!body.access_token) return undefined\n tokenCache.set(key, {\n value: body.access_token,\n expiresAt: now + (body.expires_in ?? 900) - TOKEN_EXPIRY_MARGIN_S,\n })\n return body.access_token\n } catch {\n return undefined\n }\n}\n\n/**\n * Creates or joins the person's identity at urbangate, returning the id the\n * local user row stores.\n *\n * The endpoint is idempotent on the address: a person who already has an\n * identity through another product of the suite gets that same one, with this\n * product's role added. The products therefore never own the identity, only\n * their role on it — a product deleting its local account must drop its role,\n * never deactivate the identity, or it would sign the person out of every\n * other product of the suite.\n */\nexport async function provisionIdentity(\n config: IdentityProvisioningConfig,\n request: ProvisionRequest,\n fetchImpl: typeof fetch = fetch,\n): Promise<ProvisionOutcome> {\n const token = await accessToken(config, fetchImpl)\n if (!token) return { status: \"unavailable\" }\n\n const base = config.issuer.replace(/\\/$/, \"\")\n try {\n const response = await fetchImpl(`${base}/api/machine/identities`, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n email: request.email.trim().toLowerCase(),\n email_verified: true,\n name: request.name ?? \"\",\n role: config.role,\n product: config.product,\n ...(request.password ? { password: request.password } : {}),\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n\n if (response.ok) {\n const body = (await response.json()) as IdentityResponse\n if (!body.identity_id) return { status: \"unavailable\" }\n return {\n status: \"provisioned\",\n identityId: body.identity_id,\n created: body.created === true,\n }\n }\n\n // 503 is the endpoint's retryable answer, including the inconclusive\n // lookup that refuses to risk a duplicate identity.\n if (response.status === 503 || response.status >= 500) {\n return { status: \"unavailable\" }\n }\n if (response.status === 401) {\n tokenCache.delete(`${config.issuer}|${config.clientId}`)\n return { status: \"unavailable\" }\n }\n const body = (await response.json().catch(() => ({}))) as {\n error?: string\n message?: string\n }\n return {\n status: \"rejected\",\n reason: body.error ?? body.message ?? `http_${response.status}`,\n }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n\n/**\n * Sets the password of an identity the product already enrols, for a reset or\n * a change made on the product's own form.\n *\n * `rejected` with reason `not_found` is the person having no identity yet —\n * a local account that predates the move, or one whose provisioning is still\n * to be repaired — and is worth provisioning rather than retrying.\n */\nexport async function updateIdentityPassword(\n config: IdentityProvisioningConfig,\n request: { email: string; password: string },\n fetchImpl: typeof fetch = fetch,\n): Promise<ProvisionOutcome> {\n const token = await accessToken(config, fetchImpl)\n if (!token) return { status: \"unavailable\" }\n\n const base = config.issuer.replace(/\\/$/, \"\")\n try {\n const response = await fetchImpl(`${base}/api/machine/passwords`, {\n method: \"PUT\",\n headers: {\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n email: request.email.trim().toLowerCase(),\n password: request.password,\n }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n })\n\n if (response.ok) {\n const body = (await response.json()) as IdentityResponse\n if (!body.identity_id) return { status: \"unavailable\" }\n return {\n status: \"provisioned\",\n identityId: body.identity_id,\n created: false,\n }\n }\n\n if (response.status === 503 || response.status >= 500) {\n return { status: \"unavailable\" }\n }\n if (response.status === 401) {\n tokenCache.delete(`${config.issuer}|${config.clientId}`)\n return { status: \"unavailable\" }\n }\n if (response.status === 404) {\n return { status: \"rejected\", reason: \"not_found\" }\n }\n const body = (await response.json().catch(() => ({}))) as {\n error?: string\n message?: string\n }\n return {\n status: \"rejected\",\n reason: body.error ?? body.message ?? `http_${response.status}`,\n }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n","import type { PlatformAuthConfig } from \"./types\"\n\n/**\n * Applies the platform's Google defaults without touching what the app set.\n *\n * Better Auth also accepts a function returning the options, which cannot be\n * amended without calling it — such a config is passed straight through and\n * owns its own defaults.\n */\nexport function withGoogleDefaults(\n google: NonNullable<PlatformAuthConfig[\"google\"]>,\n): NonNullable<PlatformAuthConfig[\"google\"]> {\n if (typeof google === \"function\") return google\n return {\n ...google,\n accessType: google.accessType ?? \"offline\",\n prompt: google.prompt ?? \"select_account consent\",\n }\n}\n\n","export interface SsoEndpoints {\n discoveryUrl: string\n accountIssuer: string\n authorizationUrl: string\n tokenUrl: string\n userInfoUrl: string\n}\n\n/**\n * Hydra's endpoints, derived from the issuer so that the provider comes up\n * without a discovery fetch: Better Auth refuses to initialise a provider\n * whose discovery failed unless it already knows the account issuer and the\n * endpoints, and that failure would take the whole app down at boot.\n */\nexport function ssoEndpoints(issuer: string): SsoEndpoints {\n const base = issuer.replace(/\\/$/, \"\")\n return {\n discoveryUrl: `${base}/.well-known/openid-configuration`,\n accountIssuer: base,\n authorizationUrl: `${base}/oauth2/auth`,\n tokenUrl: `${base}/oauth2/token`,\n userInfoUrl: `${base}/userinfo`,\n }\n}\n","import type { PlatformRateLimitConfig, PlatformRateLimitRule } from \"./types\"\n\n// Better Auth keys its own rate limiter on NODE_ENV === \"production\", so a\n// deployment that forgets the variable serves sign-in with no brute-force\n// protection and nothing reports it. These are the platform's rules, applied\n// regardless of the environment.\nconst PLATFORM_RULES: Record<string, PlatformRateLimitRule> = {\n \"/sign-in/email\": { window: 60, max: 5 },\n \"/sign-up/email\": { window: 60, max: 5 },\n \"/email-otp/send-verification-otp\": { window: 60, max: 3 },\n \"/email-otp/verify-email\": { window: 60, max: 5 },\n \"/email-otp/reset-password\": { window: 60, max: 5 },\n \"/forget-password\": { window: 60, max: 3 },\n \"/reset-password\": { window: 60, max: 5 },\n \"/sign-in/magic-link\": { window: 60, max: 3 },\n \"/two-factor/verify-totp\": { window: 60, max: 5 },\n \"/two-factor/verify-otp\": { window: 60, max: 5 },\n \"/two-factor/verify-backup-code\": { window: 60, max: 5 },\n \"/two-factor/send-otp\": { window: 60, max: 3 },\n}\n\nexport interface ResolvedRateLimit {\n enabled: boolean\n window: number\n max: number\n storage?: \"memory\" | \"database\" | \"secondary-storage\"\n modelName?: string\n customRules: Record<string, PlatformRateLimitRule>\n}\n\nexport function resolveRateLimit(\n config?: PlatformRateLimitConfig,\n): ResolvedRateLimit {\n return {\n enabled: config?.enabled ?? true,\n window: config?.window ?? 10,\n max: config?.max ?? 100,\n ...(config?.storage ? { storage: config.storage } : {}),\n ...(config?.modelName ? { modelName: config.modelName } : {}),\n customRules: { ...PLATFORM_RULES, ...config?.customRules },\n }\n}\n\nexport { PLATFORM_RULES }\n","import type { PlatformAuth } from \"./server\"\n\n/**\n * Minimal Postgres pool contract this module needs — kept structural so the\n * package doesn't pull in `pg` as a dependency; any `pg.Pool` satisfies it.\n */\nexport interface BootstrapAdminPool {\n connect(): Promise<BootstrapAdminClient>\n}\n\nexport interface BootstrapAdminClient {\n query(text: string, values?: unknown[]): Promise<{ rowCount: number | null }>\n release(): void\n}\n\nexport interface BootstrapFirstAdminInput {\n email: string\n password: string\n name: string\n}\n\nexport type BootstrapFirstAdminResult =\n | { ok: true }\n | { ok: false; error: \"already_completed\" }\n\n// The admin() plugin's createUser endpoint isn't in PlatformAuth's published\n// type (see the widening note on createPlatformAuth in ./server), even though\n// it's mounted at runtime by every app that enables admin().\ninterface AuthWithAdminCreateUser {\n api: {\n createUser: (input: {\n body: {\n email: string\n password: string\n name: string\n role: string\n data?: Record<string, unknown>\n }\n }) => Promise<unknown>\n }\n}\n\n/**\n * Creates the very first admin for an app, before any admin exists — the one\n * case the admin() plugin's own `createUser` endpoint can't cover, since it\n * requires an already-authenticated admin session to call.\n *\n * Delegates the actual user/account creation to `auth.api.createUser` (called\n * server-side, with no request/session, so the plugin's own auth check is\n * skipped the same way a trusted server script would be) instead of inserting\n * `user`/`account` rows by hand — that keeps this in sync with whatever\n * Better Auth's internal adapter does (account.issuer, password hashing,\n * future schema changes) rather than re-deriving it and letting the two\n * drift apart.\n *\n * Serializes concurrent callers with a Postgres advisory lock: a plain\n * `WHERE NOT EXISTS` check on an empty `user` table lets two racing requests\n * both pass before either has inserted, minting two admins.\n *\n * Callers are expected to have already applied their own access gate (setup\n * token, allowed-emails list, etc.) — this function only enforces \"at most\n * one admin, ever\".\n */\nexport async function bootstrapFirstAdmin(\n auth: PlatformAuth,\n pool: BootstrapAdminPool,\n input: BootstrapFirstAdminInput,\n): Promise<BootstrapFirstAdminResult> {\n const client = await pool.connect()\n try {\n await client.query(\"SELECT pg_advisory_lock(hashtext($1))\", [\"admin-setup\"])\n\n const existing = await client.query(\n `SELECT 1 FROM \"user\" WHERE role = 'admin'`,\n )\n if (existing.rowCount && existing.rowCount > 0) {\n return { ok: false, error: \"already_completed\" }\n }\n\n const adminApi = (auth as unknown as AuthWithAdminCreateUser).api\n await adminApi.createUser({\n body: {\n email: input.email,\n password: input.password,\n name: input.name,\n role: \"admin\",\n data: { emailVerified: true },\n },\n })\n\n return { ok: true }\n } finally {\n await client.query(\"SELECT pg_advisory_unlock(hashtext($1))\", [\"admin-setup\"])\n client.release()\n }\n}\n"],"mappings":"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","names":["body","body","body","email"]}
|
|
1
|
+
{"version":3,"sources":["../src/server.ts","../src/kratos-credentials.ts","../src/google-defaults.ts","../src/sso-endpoints.ts","../src/rate-limit.ts","../src/delete-account.ts","../src/bootstrap-admin.ts"],"sourcesContent":["import { betterAuth, APIError, type Auth, type BetterAuthOptions } from \"better-auth\"\nimport { emailOTP, admin, magicLink, twoFactor, genericOAuth } from \"better-auth/plugins\"\nimport type {\n PlatformAuthConfig,\n PlatformAuthMailerType,\n PlatformKratosPasswordConfig,\n PlatformSsoConfig,\n} from \"./types\"\nimport {\n KRATOS_SENTINEL_HASH,\n verifyAgainstKratos,\n type KratosOutcome,\n} from \"./kratos-credentials\"\nimport { provisionIdentity } from \"./identity-provisioning\"\nimport { withGoogleDefaults } from \"./google-defaults\"\nimport {\n identityIdFromIdToken,\n mapSsoProfile,\n roleFromIdToken,\n type SsoProfile,\n} from \"./sso-profile\"\nimport { ssoEndpoints } from \"./sso-endpoints\"\nimport { withSignUpName } from \"./signup-name\"\nimport { resolveRateLimit } from \"./rate-limit\"\n\nconst DEFAULT_EMAIL_SUBJECTS: Record<string, string> = {\n \"email-verification\": \"Verify your account\",\n \"forget-password\": \"Reset your password\",\n \"sign-in\": \"Your sign-in code\",\n}\n\nfunction defaultRenderOtpEmail(otp: string): string {\n return `\n <div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px\">\n <h2 style=\"font-size:20px;font-weight:600;margin-bottom:16px\">Your verification code</h2>\n <p style=\"color:#555;margin-bottom:24px\">Use the code below to continue. It expires in 5 minutes.</p>\n <div style=\"background:#f5f5f5;border-radius:8px;padding:24px;text-align:center;letter-spacing:8px;font-size:32px;font-weight:700\">\n ${otp}\n </div>\n <p style=\"color:#999;font-size:12px;margin-top:24px\">If you didn't request this, you can safely ignore this email.</p>\n </div>\n `\n}\n\nfunction defaultRenderMagicLinkEmail(url: string): string {\n return `\n <div style=\"font-family:sans-serif;max-width:480px;margin:0 auto;padding:32px\">\n <h2 style=\"font-size:20px;font-weight:600;margin-bottom:16px\">Your sign-in link</h2>\n <p style=\"color:#555;margin-bottom:24px\">Click the button below to sign in. The link expires in 5 minutes and works once.</p>\n <a href=\"${url}\" style=\"display:inline-block;background:#111;color:#fff;text-decoration:none;border-radius:8px;padding:14px 28px;font-weight:600\">Sign in</a>\n <p style=\"color:#999;font-size:12px;margin-top:24px;word-break:break-all\">Or paste this address into your browser:<br>${url}</p>\n <p style=\"color:#999;font-size:12px;margin-top:16px\">If you didn't request this, you can safely ignore this email.</p>\n </div>\n `\n}\n\n/**\n * Creates a Better Auth instance with platform defaults.\n * Each app calls this with its own config (DB, secret, providers, plugins).\n */\nexport function createPlatformAuth(\n config: PlatformAuthConfig,\n): Auth<BetterAuthOptions> {\n const {\n database,\n baseURL,\n secret,\n appName,\n mailer,\n google,\n github,\n plugins = [],\n databaseHooks,\n betaMode = false,\n isInvited,\n emailSubjects,\n renderOtpEmail,\n magicLink: magicLinkConfig,\n rateLimit,\n twoFactor: twoFactorConfig,\n trustedOrigins,\n sso,\n kratosPasswords,\n } = config\n const ssoProviderId = sso?.providerId ?? \"urbangate\"\n\n const subjects = { ...DEFAULT_EMAIL_SUBJECTS, ...emailSubjects }\n const renderEmail = renderOtpEmail ?? defaultRenderOtpEmail\n\n // The concrete instance type (with email-otp/admin plugins) is widened to\n // the base Auth type so the published .d.ts stays portable (inferring the\n // full plugin type triggers TS2742 — it can't be named without a zod ref).\n // The admin() plugin's user.role field is re-exposed via module augmentation\n // below, so consumers (e.g. transcript-web me.ts) still see session.user.role.\n return betterAuth({\n database,\n baseURL,\n secret,\n ...(trustedOrigins ? { trustedOrigins } : {}),\n rateLimit: resolveRateLimit(rateLimit),\n emailAndPassword: {\n enabled: true,\n requireEmailVerification: true,\n ...(kratosPasswords\n ? {\n password: {\n // Sign-in refuses before reaching the verifier when the account\n // carries no hash, so a sign-up must still write one. It is a\n // constant that validates nothing, never a hash of the password.\n hash: kratosHasher(kratosPasswords),\n verify: kratosVerifier(kratosPasswords),\n },\n }\n : {}),\n },\n // Never auto-merge a social identity into an existing account by matching\n // email. Better Auth links by default (email-verified providers are trusted),\n // so signing in with Google/GitHub on an email already registered would fold\n // that identity into the existing account. We keep each sign-in method its\n // own account: a social login on a taken email is refused, not linked.\n // The suite's own identity provider is the one exception: it verifies\n // emails itself, and the same person must land on the same account\n // whether they signed in here before the SSO existed or not.\n account: {\n accountLinking: sso\n ? { enabled: true, trustedProviders: [ssoProviderId] }\n : { enabled: false },\n },\n // Declared for single sign-on too, not only for Kratos passwords: the\n // suite addresses a person by their provider identity id, and an app key\n // is issued against it, so a row without one cannot ask for a key.\n ...(kratosPasswords || sso\n ? {\n user: {\n additionalFields: {\n identityId: {\n type: \"string\",\n required: false,\n input: false,\n },\n },\n },\n }\n : {}),\n // Naming happens here rather than on /sign-up/email so that every way in\n // is covered: a magic link that signs up bypasses the endpoint entirely\n // and calls createUser straight, with `name: name || \"\"`.\n databaseHooks: {\n ...databaseHooks,\n account: withSsoRoleSync(databaseHooks?.account, sso, ssoProviderId),\n user: {\n ...databaseHooks?.user,\n update: {\n ...databaseHooks?.user?.update,\n // A password sign-up is created unverified and confirmed by its OTP\n // a moment later; a social sign-up may be confirmed by the provider\n // later still. Enrolment follows the address becoming verified,\n // whenever that happens, and is idempotent so it never doubles.\n after: withIdentityProvisioning(\n databaseHooks?.user?.update?.after,\n kratosPasswords,\n ),\n },\n create: {\n ...databaseHooks?.user?.create,\n after: withIdentityProvisioning(\n databaseHooks?.user?.create?.after,\n kratosPasswords,\n ),\n before: async (user: Record<string, unknown>, ctx: unknown) => {\n const named = withSignUpName(user)\n const appHook = databaseHooks?.user?.create?.before\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const applied = await appHook?.(named as any, ctx as any)\n if (applied === false) return false\n if (applied && typeof applied === \"object\" && \"data\" in applied) {\n return { data: withSignUpName(applied.data) }\n }\n return { data: named }\n },\n },\n },\n },\n hooks: {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n before: async (ctx: any) => {\n if (kratosPasswords) {\n const body = ctx.body as\n | { email?: string; password?: string; newPassword?: string }\n | undefined\n if (ctx.path === \"/sign-in/email\" && body?.email && body?.password) {\n rememberSignInIdentifier(body.password, body.email)\n }\n // Sign-up and the OTP reset name the address they act on; a change\n // of password only has the session, whose user carries it.\n if (\n (ctx.path === \"/sign-up/email\" ||\n ctx.path === \"/email-otp/reset-password\") &&\n body?.email &&\n body?.password\n ) {\n rememberPasswordOwner(body.password, body.email)\n }\n if (ctx.path === \"/change-password\" && body?.newPassword) {\n const email = sessionEmail(ctx)\n if (email) rememberPasswordOwner(body.newPassword, email)\n }\n }\n if (!betaMode) return\n if (ctx.path !== \"/sign-up/email\") return\n const body = ctx.body as { email?: string; inviteToken?: string } | undefined\n const email = body?.email\n const inviteToken = body?.inviteToken\n if (email && inviteToken && isInvited) {\n const ok = await isInvited(email, inviteToken)\n if (ok) return\n }\n throw new APIError(\"FORBIDDEN\", {\n message: \"Registration is invite-only during the private beta.\",\n })\n },\n },\n plugins: [\n emailOTP({\n async sendVerificationOTP({ email, otp, type }) {\n const subject = subjects[type]\n ? `${subjects[type]} - ${appName}`\n : `Your ${appName} code`\n const html = renderEmail(otp, type as PlatformAuthMailerType)\n\n if (mailer) {\n await mailer({\n to: email,\n subject,\n html,\n type: type as PlatformAuthMailerType,\n otp,\n })\n return\n }\n\n console.warn(\n `[EMAIL] No mailer configured — logging OTP to stdout for ${email} (${type}): ${otp}`,\n )\n },\n otpLength: 6,\n expiresIn: 300,\n overrideDefaultEmailVerification: true,\n }),\n ...(magicLinkConfig\n ? [\n magicLink({\n expiresIn: magicLinkConfig.expiresIn ?? 300,\n // A magic link that signs up walks past both gates the platform\n // puts on the front door: requireEmailVerification, and the\n // invite-only hook, which only guards /sign-up/email.\n disableSignUp: !magicLinkConfig.allowSignUp,\n async sendMagicLink({ email, url }) {\n const subject =\n magicLinkConfig.subject ?? `Your sign-in link - ${appName}`\n const html = (\n magicLinkConfig.render ?? defaultRenderMagicLinkEmail\n )(url, email)\n\n if (mailer) {\n await mailer({\n to: email,\n subject,\n html,\n type: \"magic-link\",\n url,\n })\n return\n }\n\n console.warn(\n `[EMAIL] No mailer configured — logging magic link to stdout for ${email}: ${url}`,\n )\n },\n }),\n ]\n : []),\n admin(),\n ...(sso\n ? [\n genericOAuth({\n config: [\n {\n providerId: ssoProviderId,\n ...ssoEndpoints(sso.issuer),\n clientId: sso.clientId,\n clientSecret: sso.clientSecret,\n scopes: [\"openid\", \"email\", \"profile\", \"offline_access\"],\n pkce: true,\n overrideUserInfo: true,\n disableSignUp: sso.allowSignUp === false,\n mapProfileToUser: (profile) =>\n mapSsoProfile(profile as SsoProfile, sso.adminRole),\n },\n ],\n }),\n ]\n : []),\n ...(twoFactorConfig?.enabled\n ? [\n twoFactor({\n issuer: twoFactorConfig.issuer ?? appName,\n skipVerificationOnEnable:\n twoFactorConfig.skipVerificationOnEnable ?? false,\n }),\n ]\n : []),\n ...plugins, // app-specific plugins (e.g. tanstackStartCookies)\n ],\n socialProviders: {\n // Spread as given rather than rebuilt field by field: anything Better\n // Auth accepts belongs to the app, and a config silently dropped on the\n // way through is how an app ends up writing a plugin to put it back.\n //\n // The two defaults below are the platform's, not Google's: without\n // accessType 'offline' Google never mints a refresh token, and without\n // 'consent' it stops minting one for an account that already consented.\n // A NULL refreshToken means deleting an account can revoke the access\n // token but cannot remove the app from myaccount.google.com/permissions,\n // so the grant outlives the account it belonged to.\n ...(google ? { google: withGoogleDefaults(google) } : {}),\n ...(github ? { github } : {}),\n },\n }) as unknown as Auth<BetterAuthOptions>\n}\n\ntype UserHooks = NonNullable<NonNullable<BetterAuthOptions[\"databaseHooks\"]>[\"user\"]>\ntype UserAfterHook = NonNullable<NonNullable<UserHooks[\"create\"]>[\"after\"]>\n\n/**\n * Gives the new local user an identity at the provider and stores its id.\n *\n * This runs after the insert commits, so it cannot be atomic with the\n * sign-up: a provider that is down leaves `identityId` null and the person\n * registered all the same. The endpoint is idempotent on the address, so the\n * repair re-sends without risking a second identity.\n */\nfunction withIdentityProvisioning(\n own: UserAfterHook | undefined,\n config: PlatformKratosPasswordConfig | undefined,\n): UserAfterHook | undefined {\n if (!config) return own\n return async (user, ctx) => {\n await own?.(user, ctx)\n const record = user as {\n id?: string\n email?: string\n name?: string\n emailVerified?: boolean\n identityId?: unknown\n }\n if (!record.id || !record.email) return\n // Already enrolled: every later update of the row would otherwise call the\n // provider again for nothing.\n if (typeof record.identityId === \"string\" && record.identityId) return\n // An address nobody proved belongs to this person must not reach the\n // provider: enrolment is idempotent on the address, so an unverified one\n // would join them to the identity of whoever actually owns it. A social\n // sign-up whose provider reports the address unverified, and a password\n // sign-up before its OTP, are enrolled once the address is confirmed.\n if (record.emailVerified !== true) return\n\n const outcome = await provisionIdentity(config, {\n email: record.email,\n name: record.name,\n })\n\n if (outcome.status === \"provisioned\" && ctx) {\n await ctx.context.internalAdapter.updateUser(record.id, {\n identityId: outcome.identityId,\n })\n return\n }\n\n await config.onProvisioningDeferred?.({ userId: record.id, email: record.email })\n }\n}\n\ntype AccountHooks = NonNullable<NonNullable<BetterAuthOptions[\"databaseHooks\"]>[\"account\"]>\ntype AccountAfterHook = NonNullable<NonNullable<AccountHooks[\"create\"]>[\"after\"]>\n\n// The admin plugin declares `role` as not settable from input, so the role\n// mapProfileToUser returns is dropped when the OAuth path creates the user.\n// The account row, created then refreshed on every sign-in, carries the ID\n// token: its roles claim is what sets the local role, each time.\nfunction withSsoRoleSync(\n hooks: AccountHooks | undefined,\n sso: PlatformSsoConfig | undefined,\n providerId: string,\n): AccountHooks | undefined {\n if (!sso) return hooks\n const sync: AccountAfterHook = async (account, ctx) => {\n if (account.providerId !== providerId || !ctx) return\n const role = roleFromIdToken(account.idToken, sso.adminRole)\n // Written on every sign-in rather than at creation only: the OAuth path\n // drops what mapProfileToUser returns, and an account that predates this\n // field fills it the next time its holder signs in, with no backfill.\n const identityId = identityIdFromIdToken(account.idToken)\n const update = {\n ...(role ? { role } : {}),\n ...(identityId ? { identityId } : {}),\n }\n if (Object.keys(update).length === 0) return\n await ctx.context.internalAdapter.updateUser(account.userId, update)\n }\n const chain =\n (own: AccountAfterHook | undefined): AccountAfterHook =>\n async (account, ctx) => {\n await own?.(account, ctx)\n await sync(account, ctx)\n }\n return {\n ...hooks,\n create: { ...hooks?.create, after: chain(hooks?.create?.after) },\n update: { ...hooks?.update, after: chain(hooks?.update?.after) },\n }\n}\n\n/**\n * Better Auth's verifier is handed the stored hash and the submitted password,\n * never the address, and the same verifier serves sign-in, password change and\n * account deletion. The address of the sign-in being processed is carried here\n * by the route hook, keyed by the submitted password so two concurrent\n * sign-ins cannot read each other's.\n */\nconst pendingIdentifiers = new Map<string, string>()\n\nexport function rememberSignInIdentifier(password: string, email: string): void {\n pendingIdentifiers.set(password, email.trim().toLowerCase())\n}\n\nfunction takeSignInIdentifier(password: string): string | undefined {\n const email = pendingIdentifiers.get(password)\n pendingIdentifiers.delete(password)\n return email\n}\n\n// The same blind spot on the writing side: `hash` is handed the new password\n// and nothing else, and it is the only place Better Auth exposes it before\n// storing a placeholder in its stead. Sign-up and reset carry the address in\n// their body; a password change carries only a session, so the route hook\n// resolves it there.\nconst pendingPasswordOwners = new Map<string, string>()\n\nfunction rememberPasswordOwner(password: string, email: string): void {\n pendingPasswordOwners.set(password, email.trim().toLowerCase())\n}\n\nfunction takePasswordOwner(password: string): string | undefined {\n const email = pendingPasswordOwners.get(password)\n pendingPasswordOwners.delete(password)\n return email\n}\n\n// Writing a password is the one operation that must reach the provider: a\n// placeholder stored against a password Kratos never received is an account\n// its owner can never open. It is relayed before the local write, so a\n// provider that refuses it fails the request instead of stranding the account.\nfunction kratosHasher(config: PlatformKratosPasswordConfig) {\n return async (password: string): Promise<string> => {\n const email = takePasswordOwner(password)\n if (!email) return KRATOS_SENTINEL_HASH\n\n const outcome = await provisionIdentity(config, { email, password })\n if (outcome.status === \"provisioned\") return KRATOS_SENTINEL_HASH\n\n if (outcome.status === \"unavailable\") {\n throw new KratosSignInError(\n \"SERVICE_UNAVAILABLE\",\n \"IDENTITY_PROVIDER_UNAVAILABLE\",\n \"The identity service is unavailable. Nothing was changed — try again shortly.\",\n )\n }\n throw new KratosSignInError(\n \"UNPROCESSABLE_ENTITY\",\n \"IDENTITY_PASSWORD_REFUSED\",\n \"The identity service refused this password.\",\n )\n }\n}\n\nexport class KratosSignInError extends APIError {\n constructor(\n status:\n | \"UNAUTHORIZED\"\n | \"FORBIDDEN\"\n | \"SERVICE_UNAVAILABLE\"\n | \"UNPROCESSABLE_ENTITY\",\n code: string,\n message: string,\n ) {\n super(status, { code, message })\n }\n}\n\nfunction refusalFor(outcome: KratosOutcome): KratosSignInError | undefined {\n switch (outcome.status) {\n case \"no_credential\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"IDENTITY_HAS_NO_PASSWORD\",\n \"This account has no password yet at the identity provider. Use the password recovery to set one.\",\n )\n case \"second_factor_required\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"SECOND_FACTOR_REQUIRED\",\n \"A second factor is required to sign in.\",\n )\n case \"account_disabled\":\n return new KratosSignInError(\n \"FORBIDDEN\",\n \"ACCOUNT_DISABLED\",\n \"This account is deactivated.\",\n )\n case \"unavailable\":\n return new KratosSignInError(\n \"SERVICE_UNAVAILABLE\",\n \"IDENTITY_PROVIDER_UNAVAILABLE\",\n \"The identity service is unavailable. Your password has not been refused — try again shortly.\",\n )\n default:\n return undefined\n }\n}\n\n/**\n * Fails closed: anything other than an explicit success refuses the sign-in,\n * and only an explicit refusal by Kratos reads as a wrong password. An outage\n * answers 503, so nobody is told their password is wrong and rotates a\n * password that was right.\n */\nfunction kratosVerifier(config: PlatformKratosPasswordConfig) {\n return async ({ password }: { hash: string; password: string }): Promise<boolean> => {\n const email = takeSignInIdentifier(password)\n if (!email) return false\n const outcome = await verifyAgainstKratos(config.publicUrl, { email, password })\n if (outcome.status === \"valid\") return true\n const refusal = refusalFor(outcome)\n if (refusal) throw refusal\n return false\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction sessionEmail(ctx: any): string | undefined {\n const email = ctx?.context?.session?.user?.email\n return typeof email === \"string\" ? email : undefined\n}\n\nexport type PlatformAuth = ReturnType<typeof createPlatformAuth>\n\n// Re-export the session contract from /server so consumers that import the\n// auth factory can type api.getSession() without a second import path.\nexport type {\n PlatformUser,\n PlatformSession,\n PlatformSessionData,\n PlatformRateLimitConfig,\n PlatformRateLimitRule,\n PlatformTwoFactorConfig,\n} from \"./types\"\n\n// Invitation claiming runs on the auth callback, where the session is\n// established — the one place every sign-up flow passes through.\nexport {\n claimInvitation,\n completesSignup,\n holdInviteTokenCookie,\n invitationOutcomeCookie,\n inviteTokenFrom,\n isInvitationFailure,\n pinInviteToken,\n releaseInviteTokenCookie,\n} from \"./invitation\"\nexport type { ClaimOutcome, ClaimInvitationOptions } from \"./invitation\"\n\nexport { identityIdFromIdToken, mapSsoProfile } from \"./sso-profile\"\nexport type { SsoProfile, SsoMappedUser } from \"./sso-profile\"\n\nexport { KRATOS_SENTINEL_HASH, isKratosSentinel } from \"./kratos-credentials\"\nexport type { KratosOutcome } from \"./kratos-credentials\"\n\n// The repair path an app schedules for the sign-ups whose provisioning could\n// not reach the provider.\nexport {\n provisionIdentity,\n updateIdentityPassword,\n requestAccountDeletion,\n} from \"./identity-provisioning\"\nexport type {\n IdentityProvisioningConfig,\n ProvisionOutcome,\n DeletionOutcome,\n} from \"./identity-provisioning\"\n\nexport { deleteAccount } from \"./delete-account\"\nexport type {\n DeleteAccountConfig,\n DeleteAccountRequest,\n DeleteAccountResult,\n DeleteAccountStep,\n} from \"./delete-account\"\n\nexport { bootstrapFirstAdmin } from \"./bootstrap-admin\"\nexport type {\n BootstrapAdminPool,\n BootstrapAdminClient,\n BootstrapFirstAdminInput,\n BootstrapFirstAdminResult,\n} from \"./bootstrap-admin\"\n","/**\n * The value written in place of a password hash once Kratos holds the\n * password. Better Auth's sign-in route refuses the request before reaching\n * the verifier when the credential account carries no hash, so a row must\n * exist and be non-empty for the Kratos check to run at all.\n *\n * It is not a hash and cannot become one: argon2/bcrypt/scrypt verification\n * of this string fails on its format, so a build that ever bypassed the\n * custom verifier refuses everyone instead of admitting anyone.\n */\nexport const KRATOS_SENTINEL_HASH = \"kratos:external-credential:no-local-hash\"\n\nexport function isKratosSentinel(hash: string | null | undefined): boolean {\n return hash === KRATOS_SENTINEL_HASH\n}\n\nexport type KratosOutcome =\n | { status: \"valid\"; identityId: string }\n | { status: \"invalid_credentials\" }\n | { status: \"no_credential\" }\n | { status: \"email_not_verified\" }\n | { status: \"second_factor_required\" }\n | { status: \"account_disabled\" }\n | { status: \"unavailable\" }\n\nexport interface KratosCredentialCheck {\n email: string\n password: string\n}\n\ninterface KratosLoginFlow {\n id: string\n ui?: { nodes?: Array<{ attributes?: { name?: string; value?: string } }> }\n}\n\ninterface KratosErrorBody {\n error?: { id?: string; code?: number; reason?: string; message?: string }\n ui?: { messages?: Array<{ id?: number; text?: string; type?: string }> }\n redirect_browser_to?: string\n}\n\ninterface KratosSuccessBody {\n session?: { identity?: { id?: string; state?: string } }\n session_token?: string\n}\n\nconst FLOW_TIMEOUT_MS = 5000\n\nfunction csrfTokenOf(flow: KratosLoginFlow): string | undefined {\n return flow.ui?.nodes?.find((n) => n.attributes?.name === \"csrf_token\")\n ?.attributes?.value\n}\n\n/**\n * Kratos reports a refusal through numbered UI messages rather than a status\n * code of its own; 4000006 is the generic credential refusal, 4000010 an\n * inactive account, 4000002 a missing field. The address-unverified and\n * second-factor cases arrive as a redirect or an aal2 requirement instead.\n */\nfunction outcomeFromMessages(body: KratosErrorBody): KratosOutcome | undefined {\n const ids = (body.ui?.messages ?? []).map((m) => m.id)\n if (ids.includes(4000010)) return { status: \"account_disabled\" }\n if (ids.includes(4000006) || ids.includes(4000002)) {\n return { status: \"invalid_credentials\" }\n }\n const errorId = body.error?.id\n if (errorId === \"session_aal2_required\") {\n return { status: \"second_factor_required\" }\n }\n if (errorId === \"session_verified_address_required\") {\n return { status: \"email_not_verified\" }\n }\n if (errorId === \"browser_location_change_required\") {\n return { status: \"second_factor_required\" }\n }\n return undefined\n}\n\n/**\n * Validates a password against Kratos through the native (API) login flow,\n * which returns the outcome as JSON instead of driving a browser.\n *\n * Every failure that is not an explicit refusal by Kratos answers\n * `unavailable`, never `invalid_credentials`: a person told their password is\n * wrong during an outage changes a password that was right.\n */\nexport async function verifyAgainstKratos(\n publicUrl: string,\n check: KratosCredentialCheck,\n fetchImpl: typeof fetch = fetch,\n): Promise<KratosOutcome> {\n const base = publicUrl.replace(/\\/$/, \"\")\n let flow: KratosLoginFlow\n try {\n const started = await fetchImpl(`${base}/self-service/login/api`, {\n method: \"GET\",\n headers: { Accept: \"application/json\" },\n signal: AbortSignal.timeout(FLOW_TIMEOUT_MS),\n })\n if (!started.ok) return { status: \"unavailable\" }\n flow = (await started.json()) as KratosLoginFlow\n if (!flow?.id) return { status: \"unavailable\" }\n } catch {\n return { status: \"unavailable\" }\n }\n\n const csrf = csrfTokenOf(flow)\n try {\n const submitted = await fetchImpl(\n `${base}/self-service/login?flow=${encodeURIComponent(flow.id)}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Accept: \"application/json\" },\n body: JSON.stringify({\n method: \"password\",\n identifier: check.email.trim().toLowerCase(),\n password: check.password,\n ...(csrf ? { csrf_token: csrf } : {}),\n }),\n signal: AbortSignal.timeout(FLOW_TIMEOUT_MS),\n },\n )\n\n if (submitted.ok) {\n const body = (await submitted.json()) as KratosSuccessBody\n const identity = body.session?.identity\n if (!identity?.id) return { status: \"unavailable\" }\n if (identity.state && identity.state !== \"active\") {\n return { status: \"account_disabled\" }\n }\n return { status: \"valid\", identityId: identity.id }\n }\n\n if (submitted.status >= 500) return { status: \"unavailable\" }\n\n const body = (await submitted.json().catch(() => ({}))) as KratosErrorBody\n const mapped = outcomeFromMessages(body)\n if (mapped) return mapped\n if (submitted.status === 400 || submitted.status === 401) {\n return { status: \"invalid_credentials\" }\n }\n if (submitted.status === 403) return { status: \"second_factor_required\" }\n return { status: \"unavailable\" }\n } catch {\n return { status: \"unavailable\" }\n }\n}\n","import type { PlatformAuthConfig } from \"./types\"\n\n/**\n * Applies the platform's Google defaults without touching what the app set.\n *\n * Better Auth also accepts a function returning the options, which cannot be\n * amended without calling it — such a config is passed straight through and\n * owns its own defaults.\n */\nexport function withGoogleDefaults(\n google: NonNullable<PlatformAuthConfig[\"google\"]>,\n): NonNullable<PlatformAuthConfig[\"google\"]> {\n if (typeof google === \"function\") return google\n return {\n ...google,\n accessType: google.accessType ?? \"offline\",\n prompt: google.prompt ?? \"select_account consent\",\n }\n}\n\n","export interface SsoEndpoints {\n discoveryUrl: string\n accountIssuer: string\n authorizationUrl: string\n tokenUrl: string\n userInfoUrl: string\n}\n\n/**\n * Hydra's endpoints, derived from the issuer so that the provider comes up\n * without a discovery fetch: Better Auth refuses to initialise a provider\n * whose discovery failed unless it already knows the account issuer and the\n * endpoints, and that failure would take the whole app down at boot.\n */\nexport function ssoEndpoints(issuer: string): SsoEndpoints {\n const base = issuer.replace(/\\/$/, \"\")\n return {\n discoveryUrl: `${base}/.well-known/openid-configuration`,\n accountIssuer: base,\n authorizationUrl: `${base}/oauth2/auth`,\n tokenUrl: `${base}/oauth2/token`,\n userInfoUrl: `${base}/userinfo`,\n }\n}\n","import type { PlatformRateLimitConfig, PlatformRateLimitRule } from \"./types\"\n\n// Better Auth keys its own rate limiter on NODE_ENV === \"production\", so a\n// deployment that forgets the variable serves sign-in with no brute-force\n// protection and nothing reports it. These are the platform's rules, applied\n// regardless of the environment.\nconst PLATFORM_RULES: Record<string, PlatformRateLimitRule> = {\n \"/sign-in/email\": { window: 60, max: 5 },\n \"/sign-up/email\": { window: 60, max: 5 },\n \"/email-otp/send-verification-otp\": { window: 60, max: 3 },\n \"/email-otp/verify-email\": { window: 60, max: 5 },\n \"/email-otp/reset-password\": { window: 60, max: 5 },\n \"/forget-password\": { window: 60, max: 3 },\n \"/reset-password\": { window: 60, max: 5 },\n \"/sign-in/magic-link\": { window: 60, max: 3 },\n \"/two-factor/verify-totp\": { window: 60, max: 5 },\n \"/two-factor/verify-otp\": { window: 60, max: 5 },\n \"/two-factor/verify-backup-code\": { window: 60, max: 5 },\n \"/two-factor/send-otp\": { window: 60, max: 3 },\n}\n\nexport interface ResolvedRateLimit {\n enabled: boolean\n window: number\n max: number\n storage?: \"memory\" | \"database\" | \"secondary-storage\"\n modelName?: string\n customRules: Record<string, PlatformRateLimitRule>\n}\n\nexport function resolveRateLimit(\n config?: PlatformRateLimitConfig,\n): ResolvedRateLimit {\n return {\n enabled: config?.enabled ?? true,\n window: config?.window ?? 10,\n max: config?.max ?? 100,\n ...(config?.storage ? { storage: config.storage } : {}),\n ...(config?.modelName ? { modelName: config.modelName } : {}),\n customRules: { ...PLATFORM_RULES, ...config?.customRules },\n }\n}\n\nexport { PLATFORM_RULES }\n","import type {\n DeletionOutcome,\n IdentityProvisioningConfig,\n} from \"./identity-provisioning\"\n\nexport interface DeleteAccountConfig {\n /**\n * Ends the person's subscription. Called first and its failure is fatal:\n * an account that is deleted but keeps being charged is far worse than a\n * deletion its owner has to retry. Must be idempotent — no subscription is\n * a success, not an error, because a retried deletion reaches it again.\n *\n * Omit on a product that bills nobody.\n */\n cancelBilling?: (userId: string) => Promise<void>\n /**\n * Purges the product's own data: the domain tables keyed on the user, the\n * object storage prefix, whatever else only this product knows about.\n * Fatal, and called before anything that cannot be undone, so a failure\n * leaves an account its owner can still sign into and delete again.\n *\n * Must be idempotent: `DELETE ... WHERE user_id` on already-purged rows\n * affects none, which is what makes a retry safe.\n */\n purgeDomain?: (userId: string) => Promise<void>\n /**\n * Revokes what third parties still hold: OAuth grants on the person's\n * Google or Bluesky account, API keys, webhook endpoints. Best-effort —\n * the tokens die with the rows anyway, and a right to erasure cannot\n * depend on another company's uptime.\n */\n revokeExternal?: (userId: string) => Promise<void>\n /**\n * Drops this product's role at urbangate. Omit to leave the identity\n * untouched, which is right for a product that does not enrol one.\n */\n identity?: IdentityProvisioningConfig\n /** Reports a step that failed without stopping the deletion. */\n onWarning?: (step: string, error: unknown) => void\n /**\n * The call that asks urbangate to drop the role. Defaults to\n * `requestAccountDeletion`; an app overrides it only in a test.\n */\n requestDeletion?: (\n identity: IdentityProvisioningConfig,\n request: { identityId: string; userId?: string },\n ) => Promise<DeletionOutcome>\n}\n\nexport interface DeleteAccountRequest {\n userId: string\n /** The `identityId` on the local user row; absent on accounts that predate urbangate. */\n identityId?: string | null\n}\n\nexport type DeleteAccountResult =\n | { status: \"deleted\"; warnings: Array<string> }\n | { status: \"failed\"; step: DeleteAccountStep; cause: unknown }\n\nexport type DeleteAccountStep =\n | \"cancel_billing\"\n | \"purge_domain\"\n | \"drop_identity_role\"\n | \"delete_login\"\n\n/**\n * Deletes one product's account, in the order that leaves the least damage\n * when a step fails.\n *\n * No step can be rolled back once the next one has run, and no transaction\n * spans a payment provider, a domain database and an identity provider. What\n * the order buys is that a failure is always recoverable by retrying: billing\n * stops first because a charge that outlives the account is the one outcome\n * nobody notices, the domain data goes next while the account still exists to\n * try again from, and the login row goes last because it is what the person\n * would need to come back.\n *\n * Every step is idempotent, so the retry is safe.\n */\nexport async function deleteAccount(\n config: DeleteAccountConfig,\n request: DeleteAccountRequest,\n deleteLogin: (userId: string) => Promise<void>,\n): Promise<DeleteAccountResult> {\n const warnings: Array<string> = []\n const warn = (step: string, error: unknown) => {\n warnings.push(step)\n config.onWarning?.(step, error)\n }\n\n if (config.cancelBilling) {\n try {\n await config.cancelBilling(request.userId)\n } catch (cause) {\n return { status: \"failed\", step: \"cancel_billing\", cause }\n }\n }\n\n if (config.purgeDomain) {\n try {\n await config.purgeDomain(request.userId)\n } catch (cause) {\n return { status: \"failed\", step: \"purge_domain\", cause }\n }\n }\n\n // Before the login row goes: the tokens live on it, and a row that is gone\n // takes with it the only way to find what to revoke.\n if (config.revokeExternal) {\n try {\n await config.revokeExternal(request.userId)\n } catch (cause) {\n warn(\"revoke_external\", cause)\n }\n }\n\n if (config.identity && request.identityId) {\n // Imported here rather than at the top so this module carries no runtime\n // dependency on the provisioning client: a caller that injects its own\n // never loads it.\n const call =\n config.requestDeletion ??\n (await import(\"./identity-provisioning\")).requestAccountDeletion\n const outcome = await call(config.identity, {\n identityId: request.identityId,\n userId: request.userId,\n })\n if (outcome.status === \"unavailable\") {\n return {\n status: \"failed\",\n step: \"drop_identity_role\",\n cause: new Error(\"urbangate unavailable\"),\n }\n }\n // A rejection is urbangate refusing this request, not a transient fault:\n // retrying sends the same one. The role survives, and the warning is what\n // says so.\n if (outcome.status === \"rejected\") {\n warn(\"drop_identity_role\", new Error(outcome.reason))\n }\n }\n\n try {\n await deleteLogin(request.userId)\n } catch (cause) {\n return { status: \"failed\", step: \"delete_login\", cause }\n }\n\n return { status: \"deleted\", warnings }\n}\n","import type { PlatformAuth } from \"./server\"\n\n/**\n * Minimal Postgres pool contract this module needs — kept structural so the\n * package doesn't pull in `pg` as a dependency; any `pg.Pool` satisfies it.\n */\nexport interface BootstrapAdminPool {\n connect(): Promise<BootstrapAdminClient>\n}\n\nexport interface BootstrapAdminClient {\n query(text: string, values?: unknown[]): Promise<{ rowCount: number | null }>\n release(): void\n}\n\nexport interface BootstrapFirstAdminInput {\n email: string\n password: string\n name: string\n}\n\nexport type BootstrapFirstAdminResult =\n | { ok: true }\n | { ok: false; error: \"already_completed\" }\n\n// The admin() plugin's createUser endpoint isn't in PlatformAuth's published\n// type (see the widening note on createPlatformAuth in ./server), even though\n// it's mounted at runtime by every app that enables admin().\ninterface AuthWithAdminCreateUser {\n api: {\n createUser: (input: {\n body: {\n email: string\n password: string\n name: string\n role: string\n data?: Record<string, unknown>\n }\n }) => Promise<unknown>\n }\n}\n\n/**\n * Creates the very first admin for an app, before any admin exists — the one\n * case the admin() plugin's own `createUser` endpoint can't cover, since it\n * requires an already-authenticated admin session to call.\n *\n * Delegates the actual user/account creation to `auth.api.createUser` (called\n * server-side, with no request/session, so the plugin's own auth check is\n * skipped the same way a trusted server script would be) instead of inserting\n * `user`/`account` rows by hand — that keeps this in sync with whatever\n * Better Auth's internal adapter does (account.issuer, password hashing,\n * future schema changes) rather than re-deriving it and letting the two\n * drift apart.\n *\n * Serializes concurrent callers with a Postgres advisory lock: a plain\n * `WHERE NOT EXISTS` check on an empty `user` table lets two racing requests\n * both pass before either has inserted, minting two admins.\n *\n * Callers are expected to have already applied their own access gate (setup\n * token, allowed-emails list, etc.) — this function only enforces \"at most\n * one admin, ever\".\n */\nexport async function bootstrapFirstAdmin(\n auth: PlatformAuth,\n pool: BootstrapAdminPool,\n input: BootstrapFirstAdminInput,\n): Promise<BootstrapFirstAdminResult> {\n const client = await pool.connect()\n try {\n await client.query(\"SELECT pg_advisory_lock(hashtext($1))\", [\"admin-setup\"])\n\n const existing = await client.query(\n `SELECT 1 FROM \"user\" WHERE role = 'admin'`,\n )\n if (existing.rowCount && existing.rowCount > 0) {\n return { ok: false, error: \"already_completed\" }\n }\n\n const adminApi = (auth as unknown as AuthWithAdminCreateUser).api\n await adminApi.createUser({\n body: {\n email: input.email,\n password: input.password,\n name: input.name,\n role: \"admin\",\n data: { emailVerified: true },\n },\n })\n\n return { ok: true }\n } finally {\n await client.query(\"SELECT pg_advisory_unlock(hashtext($1))\", [\"admin-setup\"])\n client.release()\n }\n}\n"],"mappings":"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","names":["body","body","email"]}
|
|
@@ -133,6 +133,7 @@ interface SsoMappedUser {
|
|
|
133
133
|
name: string;
|
|
134
134
|
image?: string;
|
|
135
135
|
role: "admin" | "user";
|
|
136
|
+
identityId?: string;
|
|
136
137
|
}
|
|
137
138
|
/**
|
|
138
139
|
* Maps the identity provider's claims onto the local user. The admin role is
|
|
@@ -140,5 +141,12 @@ interface SsoMappedUser {
|
|
|
140
141
|
* provider is removed here the next time the person signs in.
|
|
141
142
|
*/
|
|
142
143
|
declare function mapSsoProfile(profile: SsoProfile, adminRole: string): SsoMappedUser;
|
|
144
|
+
/**
|
|
145
|
+
* The provider's identity id an ID token names, or undefined when the token
|
|
146
|
+
* cannot be read. It is the `sub` claim: the suite addresses a person by it,
|
|
147
|
+
* and an app key is issued against it, so a local row without one cannot ask
|
|
148
|
+
* for a key on that person's behalf.
|
|
149
|
+
*/
|
|
150
|
+
declare function identityIdFromIdToken(idToken: string | null | undefined): string | undefined;
|
|
143
151
|
|
|
144
|
-
export { type ClaimOutcome as C, type SsoMappedUser as S, type SsoProfile as a, type ClaimInvitationOptions as b, claimInvitation as c, completesSignup as d,
|
|
152
|
+
export { type ClaimOutcome as C, type SsoMappedUser as S, type SsoProfile as a, type ClaimInvitationOptions as b, claimInvitation as c, completesSignup as d, identityIdFromIdToken as e, invitationOutcomeCookie as f, inviteTokenFrom as g, holdInviteTokenCookie as h, isInvitationFailure as i, mapSsoProfile as m, pinInviteToken as p, releaseInviteTokenCookie as r };
|