@openlup/platform 0.15.0 → 0.16.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.
Files changed (239) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +350 -3
  3. package/dist/identity/adminAuthBinding.d.ts +28 -0
  4. package/dist/identity/adminAuthBinding.js +66 -0
  5. package/dist/identity/adminAuthorization.d.ts +34 -0
  6. package/dist/identity/adminAuthorization.js +72 -0
  7. package/dist/identity/adminContracts.d.ts +70 -0
  8. package/dist/identity/adminContracts.js +43 -0
  9. package/dist/identity/adminMagicLinkHandler.d.ts +39 -0
  10. package/dist/identity/adminMagicLinkHandler.js +94 -0
  11. package/dist/identity/adminMeHandler.d.ts +15 -0
  12. package/dist/identity/adminMeHandler.js +49 -0
  13. package/dist/identity/adminPreflight.d.ts +16 -0
  14. package/dist/identity/adminPreflight.js +38 -0
  15. package/dist/identity/adminUserActionPorts.d.ts +31 -0
  16. package/dist/identity/adminUserActionPorts.js +51 -0
  17. package/dist/identity/adminUserHandlers.d.ts +38 -0
  18. package/dist/identity/adminUserHandlers.js +140 -0
  19. package/dist/identity/authVerifyUrl.d.ts +6 -0
  20. package/dist/identity/authVerifyUrl.js +54 -0
  21. package/dist/identity/contracts/contracts.d.ts +8 -0
  22. package/dist/identity/contracts/contracts.js +2 -0
  23. package/dist/identity/contracts/emailTrust.d.ts +3 -0
  24. package/dist/identity/contracts/emailTrust.js +4 -0
  25. package/dist/identity/contracts/index.d.ts +8 -0
  26. package/dist/identity/contracts/index.js +8 -0
  27. package/dist/identity/contracts/oidc.d.ts +3 -0
  28. package/dist/identity/contracts/oidc.js +9 -0
  29. package/dist/identity/contracts/operatorAuth.d.ts +35 -0
  30. package/dist/identity/contracts/operatorAuth.js +53 -0
  31. package/dist/identity/contracts/operatorInvitations.d.ts +37 -0
  32. package/dist/identity/contracts/operatorInvitations.js +1 -0
  33. package/dist/identity/contracts/ports.d.ts +22 -0
  34. package/dist/identity/contracts/ports.js +1 -0
  35. package/dist/identity/contracts/privacy.d.ts +62 -0
  36. package/dist/identity/contracts/privacy.js +99 -0
  37. package/dist/identity/contracts/providerList.d.ts +3 -0
  38. package/dist/identity/contracts/providerList.js +11 -0
  39. package/dist/identity/contracts/types.d.ts +16 -0
  40. package/dist/identity/contracts/types.js +4 -0
  41. package/dist/identity/contribution.d.ts +8 -0
  42. package/dist/identity/contribution.js +22 -0
  43. package/dist/identity/customerAuth.d.ts +8 -0
  44. package/dist/identity/customerAuth.js +1 -0
  45. package/dist/identity/customerAuthorization.d.ts +5 -0
  46. package/dist/identity/customerAuthorization.js +22 -0
  47. package/dist/identity/customerContracts.d.ts +42 -0
  48. package/dist/identity/customerContracts.js +43 -0
  49. package/dist/identity/customerIdentityBinding.d.ts +44 -0
  50. package/dist/identity/customerIdentityBinding.js +60 -0
  51. package/dist/identity/customerIdentityVerifier.d.ts +8 -0
  52. package/dist/identity/customerIdentityVerifier.js +101 -0
  53. package/dist/identity/customerMagicLinkHandler.d.ts +38 -0
  54. package/dist/identity/customerMagicLinkHandler.js +115 -0
  55. package/dist/identity/customerMeHandler.d.ts +11 -0
  56. package/dist/identity/customerMeHandler.js +46 -0
  57. package/dist/identity/customerPorts.d.ts +4 -0
  58. package/dist/identity/customerPorts.js +1 -0
  59. package/dist/identity/customerSessionBinding.d.ts +38 -0
  60. package/dist/identity/customerSessionBinding.js +80 -0
  61. package/dist/identity/customerSessionIssuer.d.ts +10 -0
  62. package/dist/identity/customerSessionIssuer.js +57 -0
  63. package/dist/identity/customerVerifyOtpHandler.d.ts +8 -0
  64. package/dist/identity/customerVerifyOtpHandler.js +41 -0
  65. package/dist/identity/directOperatorInvitationStore.d.ts +19 -0
  66. package/dist/identity/directOperatorInvitationStore.js +193 -0
  67. package/dist/identity/emailTrustPolicy.d.ts +14 -0
  68. package/dist/identity/emailTrustPolicy.js +16 -0
  69. package/dist/identity/identityPersonalDataContributor.d.ts +7 -0
  70. package/dist/identity/identityPersonalDataContributor.js +46 -0
  71. package/dist/identity/identityReadiness.d.ts +10 -0
  72. package/dist/identity/identityReadiness.js +25 -0
  73. package/dist/identity/index.d.ts +67 -0
  74. package/dist/identity/index.js +66 -0
  75. package/dist/identity/inviteAdminUserUseCase.d.ts +89 -0
  76. package/dist/identity/inviteAdminUserUseCase.js +75 -0
  77. package/dist/identity/jwtLibrary.d.ts +54 -0
  78. package/dist/identity/jwtLibrary.js +1 -0
  79. package/dist/identity/machineActorWriteFence.d.ts +4 -0
  80. package/dist/identity/machineActorWriteFence.js +13 -0
  81. package/dist/identity/magicLinkPorts.d.ts +28 -0
  82. package/dist/identity/magicLinkPorts.js +1 -0
  83. package/dist/identity/managedAccountLinkStore.d.ts +2 -0
  84. package/dist/identity/managedAccountLinkStore.js +93 -0
  85. package/dist/identity/managedAdminAuthVerifier.d.ts +7 -0
  86. package/dist/identity/managedAdminAuthVerifier.js +27 -0
  87. package/dist/identity/managedAdminUserGateways.d.ts +123 -0
  88. package/dist/identity/managedAdminUserGateways.js +127 -0
  89. package/dist/identity/managedClient.d.ts +90 -0
  90. package/dist/identity/managedClient.js +10 -0
  91. package/dist/identity/managedClientAuthLink.d.ts +3 -0
  92. package/dist/identity/managedClientAuthLink.js +115 -0
  93. package/dist/identity/managedCustomerMe.d.ts +13 -0
  94. package/dist/identity/managedCustomerMe.js +30 -0
  95. package/dist/identity/managedEmailTrustPolicy.d.ts +11 -0
  96. package/dist/identity/managedEmailTrustPolicy.js +105 -0
  97. package/dist/identity/managedIdentityVerifier.d.ts +2 -0
  98. package/dist/identity/managedIdentityVerifier.js +16 -0
  99. package/dist/identity/managedPrivacy.d.ts +4 -0
  100. package/dist/identity/managedPrivacy.js +39 -0
  101. package/dist/identity/messages.d.ts +29 -0
  102. package/dist/identity/messages.js +39 -0
  103. package/dist/identity/oidcIdentityVerifier.d.ts +45 -0
  104. package/dist/identity/oidcIdentityVerifier.js +309 -0
  105. package/dist/identity/oidcPrincipal.d.ts +2 -0
  106. package/dist/identity/oidcPrincipal.js +10 -0
  107. package/dist/identity/operatorAdminAuth.d.ts +6 -0
  108. package/dist/identity/operatorAdminAuth.js +13 -0
  109. package/dist/identity/operatorAuthHandlers.d.ts +18 -0
  110. package/dist/identity/operatorAuthHandlers.js +71 -0
  111. package/dist/identity/operatorIdentityVerifier.d.ts +8 -0
  112. package/dist/identity/operatorIdentityVerifier.js +19 -0
  113. package/dist/identity/operatorInvitationLifecycle.d.ts +7 -0
  114. package/dist/identity/operatorInvitationLifecycle.js +15 -0
  115. package/dist/identity/operatorInvitationPorts.d.ts +31 -0
  116. package/dist/identity/operatorInvitationPorts.js +1 -0
  117. package/dist/identity/operatorPorts.d.ts +37 -0
  118. package/dist/identity/operatorPorts.js +1 -0
  119. package/dist/identity/operatorSession.d.ts +13 -0
  120. package/dist/identity/operatorSession.js +31 -0
  121. package/dist/identity/operatorTokenAdminAuth.d.ts +15 -0
  122. package/dist/identity/operatorTokenAdminAuth.js +50 -0
  123. package/dist/identity/personalDataRegistry.d.ts +4 -0
  124. package/dist/identity/personalDataRegistry.js +11 -0
  125. package/dist/identity/platformAdminAuth.d.ts +15 -0
  126. package/dist/identity/platformAdminAuth.js +1 -0
  127. package/dist/identity/ports.d.ts +127 -0
  128. package/dist/identity/ports.js +38 -0
  129. package/dist/identity/postgresAccountLinkStore.d.ts +7 -0
  130. package/dist/identity/postgresAccountLinkStore.js +52 -0
  131. package/dist/identity/postgresCustomerMe.d.ts +3 -0
  132. package/dist/identity/postgresCustomerMe.js +21 -0
  133. package/dist/identity/postgresCustomerRecovery.d.ts +11 -0
  134. package/dist/identity/postgresCustomerRecovery.js +57 -0
  135. package/dist/identity/postgresOperatorIdentity.d.ts +5 -0
  136. package/dist/identity/postgresOperatorIdentity.js +137 -0
  137. package/dist/identity/postgresPrivacy.d.ts +7 -0
  138. package/dist/identity/postgresPrivacy.js +126 -0
  139. package/dist/identity/privacyHandlers.d.ts +10 -0
  140. package/dist/identity/privacyHandlers.js +35 -0
  141. package/dist/identity/privacyPorts.d.ts +72 -0
  142. package/dist/identity/privacyPorts.js +8 -0
  143. package/dist/identity/privacyUseCases.d.ts +5 -0
  144. package/dist/identity/privacyUseCases.js +55 -0
  145. package/dist/identity/reconcileAccountForPrincipal.d.ts +20 -0
  146. package/dist/identity/reconcileAccountForPrincipal.js +66 -0
  147. package/dist/identity/reconcileAccountHandler.d.ts +11 -0
  148. package/dist/identity/reconcileAccountHandler.js +30 -0
  149. package/dist/identity/removeAdminUserUseCase.d.ts +31 -0
  150. package/dist/identity/removeAdminUserUseCase.js +60 -0
  151. package/dist/identity/response.d.ts +3 -0
  152. package/dist/identity/response.js +2 -0
  153. package/dist/identity/sql.d.ts +44 -0
  154. package/dist/identity/sql.js +10 -0
  155. package/package.json +12 -2
  156. package/release-gates.json +22 -0
  157. package/sql/20260810180001_self_host_actor_auth.sql +40 -0
  158. package/sql/20260810204057_customer_identity_read.sql +31 -0
  159. package/sql/20260814090000_customer_selfservice_recovery.sql +477 -0
  160. package/sql/20260901143000_admin_membership_authority_foundation.sql +234 -0
  161. package/sql/20261018120000_identity_operator_invitations.sql +42 -0
  162. package/sql/20261018180000_identity_operator_challenges.sql +56 -0
  163. package/sql/20261018190000_identity_privacy_erasures.sql +15 -0
  164. package/src/identity/adminAuthBinding.ts +76 -0
  165. package/src/identity/adminAuthorization.ts +106 -0
  166. package/src/identity/adminContracts.ts +52 -0
  167. package/src/identity/adminMagicLinkHandler.ts +132 -0
  168. package/src/identity/adminMeHandler.ts +64 -0
  169. package/src/identity/adminPreflight.ts +46 -0
  170. package/src/identity/adminUserActionPorts.ts +78 -0
  171. package/src/identity/adminUserHandlers.ts +174 -0
  172. package/src/identity/authVerifyUrl.ts +56 -0
  173. package/src/identity/contracts/contracts.ts +15 -0
  174. package/src/identity/contracts/emailTrust.ts +5 -0
  175. package/src/identity/contracts/index.ts +8 -0
  176. package/src/identity/contracts/oidc.ts +10 -0
  177. package/src/identity/contracts/operatorAuth.ts +61 -0
  178. package/src/identity/contracts/operatorInvitations.ts +32 -0
  179. package/src/identity/contracts/ports.ts +25 -0
  180. package/src/identity/contracts/privacy.ts +86 -0
  181. package/src/identity/contracts/providerList.ts +13 -0
  182. package/src/identity/contracts/types.ts +25 -0
  183. package/src/identity/contribution.ts +29 -0
  184. package/src/identity/customerAuth.ts +3 -0
  185. package/src/identity/customerAuthorization.ts +38 -0
  186. package/src/identity/customerContracts.ts +52 -0
  187. package/src/identity/customerIdentityBinding.ts +125 -0
  188. package/src/identity/customerIdentityVerifier.ts +141 -0
  189. package/src/identity/customerMagicLinkHandler.ts +171 -0
  190. package/src/identity/customerMeHandler.ts +64 -0
  191. package/src/identity/customerPorts.ts +2 -0
  192. package/src/identity/customerSessionBinding.ts +114 -0
  193. package/src/identity/customerSessionIssuer.ts +71 -0
  194. package/src/identity/customerVerifyOtpHandler.ts +44 -0
  195. package/src/identity/directOperatorInvitationStore.ts +178 -0
  196. package/src/identity/emailTrustPolicy.ts +16 -0
  197. package/src/identity/identityPersonalDataContributor.ts +48 -0
  198. package/src/identity/identityReadiness.ts +26 -0
  199. package/src/identity/index.ts +69 -0
  200. package/src/identity/inviteAdminUserUseCase.ts +134 -0
  201. package/src/identity/jwtLibrary.ts +24 -0
  202. package/src/identity/machineActorWriteFence.ts +19 -0
  203. package/src/identity/magicLinkPorts.ts +7 -0
  204. package/src/identity/managedAccountLinkStore.ts +105 -0
  205. package/src/identity/managedAdminAuthVerifier.ts +40 -0
  206. package/src/identity/managedAdminUserGateways.ts +164 -0
  207. package/src/identity/managedClient.ts +58 -0
  208. package/src/identity/managedClientAuthLink.ts +120 -0
  209. package/src/identity/managedCustomerMe.ts +52 -0
  210. package/src/identity/managedEmailTrustPolicy.ts +90 -0
  211. package/src/identity/managedIdentityVerifier.ts +20 -0
  212. package/src/identity/managedPrivacy.ts +37 -0
  213. package/src/identity/messages.ts +41 -0
  214. package/src/identity/oidcIdentityVerifier.ts +229 -0
  215. package/src/identity/oidcPrincipal.ts +10 -0
  216. package/src/identity/operatorAdminAuth.ts +14 -0
  217. package/src/identity/operatorAuthHandlers.ts +52 -0
  218. package/src/identity/operatorIdentityVerifier.ts +19 -0
  219. package/src/identity/operatorInvitationLifecycle.ts +14 -0
  220. package/src/identity/operatorInvitationPorts.ts +11 -0
  221. package/src/identity/operatorPorts.ts +11 -0
  222. package/src/identity/operatorSession.ts +30 -0
  223. package/src/identity/operatorTokenAdminAuth.ts +79 -0
  224. package/src/identity/personalDataRegistry.ts +8 -0
  225. package/src/identity/platformAdminAuth.ts +7 -0
  226. package/src/identity/ports.ts +158 -0
  227. package/src/identity/postgresAccountLinkStore.ts +49 -0
  228. package/src/identity/postgresCustomerMe.ts +33 -0
  229. package/src/identity/postgresCustomerRecovery.ts +72 -0
  230. package/src/identity/postgresOperatorIdentity.ts +123 -0
  231. package/src/identity/postgresPrivacy.ts +117 -0
  232. package/src/identity/privacyHandlers.ts +33 -0
  233. package/src/identity/privacyPorts.ts +52 -0
  234. package/src/identity/privacyUseCases.ts +48 -0
  235. package/src/identity/reconcileAccountForPrincipal.ts +99 -0
  236. package/src/identity/reconcileAccountHandler.ts +36 -0
  237. package/src/identity/removeAdminUserUseCase.ts +85 -0
  238. package/src/identity/response.ts +3 -0
  239. package/src/identity/sql.ts +10 -0
@@ -0,0 +1,58 @@
1
+ import type { SupabaseCreateClient } from "../data/index.js";
2
+
3
+ export interface ManagedIdentityUser {
4
+ id: string;
5
+ email?: string | null;
6
+ email_confirmed_at?: string | null;
7
+ user_metadata?: Record<string, unknown> | null;
8
+ }
9
+ export interface ManagedIdentityRow {
10
+ id: string;
11
+ email?: string | null;
12
+ auth_user_id?: string | null;
13
+ role?: string | null;
14
+ is_machine_actor?: boolean | null;
15
+ membership_state?: string;
16
+ }
17
+ export interface ManagedIdentityQuery extends PromiseLike<{ data: ManagedIdentityRow[] | null; error: unknown }> {
18
+ select(columns: string): ManagedIdentityQuery;
19
+ eq(column: string, value: string): ManagedIdentityQuery;
20
+ filter(column: string, operator: "imatch", value: string): ManagedIdentityQuery;
21
+ is(column: string, value: null): ManagedIdentityQuery;
22
+ order(column: string, options: { ascending: boolean }): ManagedIdentityQuery;
23
+ limit(count: number): ManagedIdentityQuery;
24
+ maybeSingle(): PromiseLike<{ data: ManagedIdentityRow | null; error: unknown }>;
25
+ single(): PromiseLike<{ data: ManagedIdentityRow | null; error: unknown }>;
26
+ }
27
+ export interface ManagedIdentityTable {
28
+ select(columns: string): ManagedIdentityQuery;
29
+ update(value: Record<string, unknown>): ManagedIdentityQuery;
30
+ insert(value: Record<string, unknown>): ManagedIdentityQuery;
31
+ }
32
+ /** Only the methods consumed by the identity adapters; the host owns the SDK. */
33
+ export interface ManagedIdentityClient {
34
+ from(table: string): ManagedIdentityTable;
35
+ auth: {
36
+ getUser(token: string): PromiseLike<{ data: { user: ManagedIdentityUser | null }; error: unknown }>;
37
+ admin: {
38
+ listUsers(options: { page: number; perPage: number }): PromiseLike<{
39
+ data: { users: ManagedIdentityUser[]; nextPage?: number | null }; error: unknown;
40
+ }>;
41
+ getUserById(id: string): PromiseLike<{ data: { user: ManagedIdentityUser | null }; error: unknown }>;
42
+ createUser(input: { email: string; email_confirm: boolean }): PromiseLike<{ data: { user: ManagedIdentityUser | null }; error: unknown }>;
43
+ };
44
+ };
45
+ }
46
+
47
+ export function createManagedIdentityClients(createClient: SupabaseCreateClient) {
48
+ const actor = (env: { url: string; anonKey: string }, accessToken: string | null): ManagedIdentityClient =>
49
+ createClient(env.url, env.anonKey, {
50
+ auth: { persistSession: false, autoRefreshToken: false },
51
+ global: { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} },
52
+ }) as ManagedIdentityClient;
53
+ const service = (env: { url: string; serviceRoleKey: string }): ManagedIdentityClient =>
54
+ createClient(env.url, env.serviceRoleKey, {
55
+ auth: { persistSession: false, autoRefreshToken: false },
56
+ }) as ManagedIdentityClient;
57
+ return { actor, service };
58
+ }
@@ -0,0 +1,120 @@
1
+ import type { EmailTrustPolicy } from "./emailTrustPolicy.js";
2
+ import type { ManagedIdentityClient, ManagedIdentityRow, ManagedIdentityUser } from "./managedClient.js";
3
+ import type { ClientAuthLinkStore } from "./ports.js";
4
+ export function createSupabaseClientAuthLinkStore(rawClient: unknown, emailTrustPolicy: EmailTrustPolicy): ClientAuthLinkStore {
5
+ const client = rawClient as ManagedIdentityClient;
6
+ return {
7
+ emailTrustPolicy,
8
+ async findClientByEmail(email) {
9
+ const rows = await findManagedContactsByEmail(client, email, true);
10
+ const row = rows[0];
11
+ if (!row) return null;
12
+ return { id: row.id, authUserId: row.auth_user_id ?? null };
13
+ },
14
+
15
+ async findAuthUserIdByEmail(email) {
16
+ const perPage = 1000;
17
+ const maxUsers = 5000;
18
+ let page = 1;
19
+ let scanned = 0;
20
+
21
+ while (scanned < maxUsers) {
22
+ const { data, error } = await client.auth.admin.listUsers({ page, perPage });
23
+ if (error) throw error;
24
+ const users: ManagedIdentityUser[] = data.users;
25
+ const match = users.find(
26
+ (u) => (u.email ?? "").trim().toLowerCase() === email,
27
+ );
28
+ if (match) return match.id;
29
+ scanned += users.length;
30
+ const nextPage = "nextPage" in data ? data.nextPage : null;
31
+ if (!nextPage) return null;
32
+ page = nextPage;
33
+ }
34
+
35
+ throw new Error("auth_user_lookup_cap_exceeded");
36
+ },
37
+
38
+ async findAuthUserEmailById(authUserId) {
39
+ const { data, error } = await client.auth.admin.getUserById(authUserId);
40
+ if (error) throw error;
41
+ return data.user?.email ?? null;
42
+ },
43
+
44
+ async createAuthUser(email) {
45
+ const { data, error } = await client.auth.admin.createUser({
46
+ email,
47
+ email_confirm: false,
48
+ });
49
+ if (error && isAlreadyRegistered(error)) return null;
50
+ if (error) throw error;
51
+ if (!data.user) throw new Error("createUser returned no user");
52
+ return data.user.id;
53
+ },
54
+
55
+ async findAdminIdentity(authUserId) {
56
+ const { data, error } = await client
57
+ .from("admin_users")
58
+ .select("id, is_machine_actor")
59
+ .eq("id", authUserId)
60
+ .maybeSingle();
61
+ if (error) throw error;
62
+ if (!data) return null;
63
+ return { isMachineActor: data.is_machine_actor !== false };
64
+ },
65
+
66
+ async setClientAuthUserId(clientId, authUserId) {
67
+ const { data, error } = await client
68
+ .from("clients")
69
+ .update({ auth_user_id: authUserId })
70
+ .eq("id", clientId)
71
+ .is("auth_user_id", null)
72
+ .select("auth_user_id")
73
+ .maybeSingle();
74
+ if (error) throw error;
75
+ if (data?.auth_user_id === authUserId) {
76
+ return { ok: true, alreadyLinked: false };
77
+ }
78
+
79
+ const { data: current, error: currentError } = await client
80
+ .from("clients")
81
+ .select("auth_user_id")
82
+ .eq("id", clientId)
83
+ .maybeSingle();
84
+ if (currentError) throw currentError;
85
+ if (current?.auth_user_id === authUserId) {
86
+ return { ok: true, alreadyLinked: true };
87
+ }
88
+ return {
89
+ ok: false,
90
+ code: "CLIENT_AUTH_LINK_CONFLICT",
91
+ existingAuthUserId: current?.auth_user_id ?? null,
92
+ };
93
+ },
94
+ };
95
+ }
96
+
97
+
98
+ function isAlreadyRegistered(error: unknown): boolean {
99
+ const failure = (error ?? {}) as { code?: unknown; status?: unknown; message?: unknown };
100
+ if (failure.code === "email_exists" || failure.code === "user_already_exists") return true;
101
+ return failure.status === 422 && typeof failure.message === "string"
102
+ && /already (been )?registered/i.test(failure.message);
103
+ }
104
+
105
+ async function findManagedContactsByEmail(
106
+ client: ManagedIdentityClient,
107
+ email: string,
108
+ oldestFirst: boolean,
109
+ ): Promise<ManagedIdentityRow[]> {
110
+ const normalized = email.trim().toLowerCase();
111
+ // Escaping bounds the database prefilter; Unicode regex folding can still
112
+ // widen it, so only exact normalized addresses may cross this boundary.
113
+ let query = client.from("clients")
114
+ .select("id, email, auth_user_id")
115
+ .filter("email", "imatch", `^${normalized.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&")}$`);
116
+ if (oldestFirst) query = query.order("created_at", { ascending: true });
117
+ const { data, error } = await query.limit(10);
118
+ if (error) throw error;
119
+ return (data ?? []).filter((row) => row.email?.trim().toLowerCase() === normalized);
120
+ }
@@ -0,0 +1,52 @@
1
+ import type { ManagedIdentityClient } from "./managedClient.js";
2
+ import type { CustomerMeResponse } from "./customerContracts.js";
3
+ import type { CustomerMePort } from "./customerPorts.js";
4
+
5
+ type ManagedCustomerClient = ManagedIdentityClient;
6
+ type LinkedCustomerRow = {
7
+ id: string;
8
+ email: string;
9
+ first_name: string | null;
10
+ last_name: string | null;
11
+ phone: string | null;
12
+ lifecycle_stage: CustomerMeResponse["lifecycleStage"];
13
+ };
14
+
15
+ export async function readLinkedCustomer(
16
+ rawClient: unknown,
17
+ userId: string,
18
+ includePhone = false,
19
+ ): Promise<LinkedCustomerRow | null> {
20
+ const client = rawClient as ManagedCustomerClient;
21
+ const { data, error } = await client
22
+ .from("clients")
23
+ .select(includePhone
24
+ ? "id, email, first_name, last_name, phone, lifecycle_stage"
25
+ : "id, email, first_name, last_name, lifecycle_stage")
26
+ .eq("auth_user_id", userId)
27
+ .maybeSingle();
28
+ if (error) throw error;
29
+ return data as unknown as LinkedCustomerRow | null;
30
+ }
31
+
32
+
33
+ export function createSupabaseCustomerMePort(
34
+ rawClient: unknown,
35
+ ): CustomerMePort {
36
+ const client = rawClient as ManagedCustomerClient;
37
+ return {
38
+ async getCustomerMe(userId): Promise<CustomerMeResponse | null> {
39
+ const data = await readLinkedCustomer(client, userId);
40
+ if (!data) return null;
41
+
42
+ return {
43
+ clientId: data.id,
44
+ email: data.email,
45
+ firstName: data.first_name,
46
+ lastName: data.last_name,
47
+ lifecycleStage:
48
+ data.lifecycle_stage as CustomerMeResponse["lifecycleStage"],
49
+ };
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,90 @@
1
+ import type { EmailTrustAssessment, EmailTrustPolicy } from "./emailTrustPolicy.js";
2
+
3
+ const unavailable: EmailTrustAssessment = { trusted: false, code: "EMAIL_TRUST_SETTINGS_UNAVAILABLE" };
4
+ const refused: EmailTrustAssessment = { trusted: false, code: "EMAIL_TRUST_ENVELOPE_REFUSED" };
5
+ const providerId = /^[a-z][a-z0-9_-]*$/;
6
+ /** Server-side assertion by the adopter, never a provider certification. */
7
+ export function parseEmailVerifyingProviders(value: string): readonly string[] {
8
+ if (!value.trim()) return [];
9
+ const entries = value.split(",").map(entry => entry.trim().toLowerCase());
10
+ if (entries.some(entry => !providerId.test(entry))) throw new Error("Invalid email-verifying provider declaration");
11
+ return [...new Set(entries)].sort();
12
+ }
13
+
14
+ /** One immutable origin/key/allowlist binding; import and construction perform no I/O. */
15
+ export function createManagedEmailTrustPolicy(options: {
16
+ authBaseUrl: string;
17
+ publicKey: string;
18
+ emailVerifyingProviders?: readonly string[];
19
+ fetch?: typeof globalThis.fetch;
20
+ now?: () => number;
21
+ }): EmailTrustPolicy {
22
+ const origin = new URL(options.authBaseUrl);
23
+ if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password
24
+ || origin.pathname !== "/" || origin.search || origin.hash || !options.publicKey.trim()) {
25
+ throw new Error("Invalid identity email-trust configuration");
26
+ }
27
+ const url = new URL("/auth/v1/settings", origin);
28
+ const publicKey = options.publicKey;
29
+ const allowed = new Set(parseEmailVerifyingProviders((options.emailVerifyingProviders ?? []).join(",")));
30
+ const fetchSettings = options.fetch ?? globalThis.fetch;
31
+ const now = options.now ?? Date.now;
32
+ let cached: { result: EmailTrustAssessment; expires: number } | undefined;
33
+ let inFlight: Promise<EmailTrustAssessment> | undefined;
34
+
35
+ function decode(value: unknown): EmailTrustAssessment {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) return unavailable;
37
+ const { mailer_autoconfirm: automatic, external } = value as Record<string, unknown>;
38
+ if (typeof automatic !== "boolean" || !external || typeof external !== "object" || Array.isArray(external)) return unavailable;
39
+ const entries = Object.entries(external);
40
+ if (entries.some(([key, enabled]) => !providerId.test(key) || typeof enabled !== "boolean")) return unavailable;
41
+ if (automatic || entries.some(([key, enabled]) => enabled && key !== "email" && !allowed.has(key))) return refused;
42
+ return { trusted: true };
43
+ }
44
+
45
+ async function load(): Promise<EmailTrustAssessment> {
46
+ const controller = new AbortController();
47
+ let timer: ReturnType<typeof setTimeout> | undefined;
48
+ const deadline = new Promise<never>((_, reject) => {
49
+ timer = setTimeout(() => { controller.abort(); reject(new Error("Identity settings deadline")); }, 3000);
50
+ });
51
+ async function read(): Promise<EmailTrustAssessment> {
52
+ const response = await fetchSettings(url, {
53
+ headers: { apikey: publicKey }, redirect: "error", signal: controller.signal,
54
+ });
55
+ if (!response.ok || response.redirected || !response.body) return unavailable;
56
+ const reader = response.body.getReader();
57
+ let bytes = 0;
58
+ const chunks: Uint8Array[] = [];
59
+ try {
60
+ while (true) {
61
+ const chunk = await reader.read();
62
+ if (chunk.done) break;
63
+ bytes += chunk.value.byteLength;
64
+ if (bytes > 65536) { controller.abort(); void reader.cancel().catch(() => {}); return unavailable; }
65
+ chunks.push(chunk.value);
66
+ }
67
+ } finally { reader.releaseLock(); }
68
+ const body = new Uint8Array(bytes);
69
+ let offset = 0;
70
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
71
+ return decode(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)));
72
+ }
73
+ try { return await Promise.race([read(), deadline]); }
74
+ catch { return unavailable; }
75
+ finally { controller.abort(); clearTimeout(timer); }
76
+ }
77
+ return {
78
+ async assess() {
79
+ if (cached && now() < cached.expires) return cached.result;
80
+ if (!inFlight) {
81
+ inFlight = load().then(result => {
82
+ const ttl = !result.trusted && result.code === "EMAIL_TRUST_SETTINGS_UNAVAILABLE" ? 5000 : 60000;
83
+ cached = { result, expires: now() + ttl };
84
+ return result;
85
+ }).finally(() => { inFlight = undefined; });
86
+ }
87
+ return inFlight;
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,20 @@
1
+ import type { ManagedIdentityClient } from "./managedClient.js";
2
+ import type { IdentityVerifierPort, VerifiedPrincipal } from "./ports.js";
3
+
4
+
5
+ export function createSupabaseIdentityVerifier(rawClient: unknown): IdentityVerifierPort {
6
+ const client = rawClient as ManagedIdentityClient;
7
+ return {
8
+ async verifyAccessToken(accessToken: string): Promise<VerifiedPrincipal | null> {
9
+ const { data, error } = await client.auth.getUser(accessToken);
10
+ if (error || !data.user) return null;
11
+
12
+ const user = data.user;
13
+ return {
14
+ principalId: user.id,
15
+ email: user.email ?? null,
16
+ emailVerified: Boolean(user.email_confirmed_at),
17
+ };
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,37 @@
1
+ import type { ManagedIdentityClient } from "./managedClient.js";
2
+ import { PrivacyRefusal, type PrivacyStore } from "./privacyPorts.js";
3
+ import type { PrivacyJson } from "./contracts/privacy.js";
4
+
5
+ /** Reads only committed service projections. No managed write or provider delete is composed. */
6
+ export function createManagedPrivacyStore(): PrivacyStore<ManagedIdentityClient> {
7
+ const refuse = async (): Promise<never> => { throw new PrivacyRefusal("MANAGED_PRIVACY_ERASURE_UNAVAILABLE"); };
8
+ return {
9
+ erasureSupported: false, prepareErasure: refuse, authorizeErasure: refuse, eraseIdentity: refuse, saveReceipt: refuse,
10
+ async findReceipt() { return null; },
11
+ async resolve(client, ref) {
12
+ const query = client.from("clients").select("id, email, auth_user_id, first_name, last_name, phone");
13
+ const { data: contact, error } = await query.eq(ref.kind === "contact" ? "id" : "auth_user_id", ref.kind === "contact" ? ref.contactId : ref.principalId).maybeSingle();
14
+ if (error) throw new PrivacyRefusal("PRIVACY_ASSESSMENT_UNAVAILABLE");
15
+ const principalId = ref.kind === "principal" ? ref.principalId : contact?.auth_user_id ?? null;
16
+ let principal: PrivacyJson = null;
17
+ let membership: PrivacyJson = null;
18
+ if (principalId) {
19
+ const result = await client.auth.admin.getUserById(principalId);
20
+ if (result.error) {
21
+ const failure = result.error as { status?: unknown; code?: unknown };
22
+ if (!contact && (failure.status === 404 || failure.code === "user_not_found")) return null;
23
+ throw new PrivacyRefusal("PRIVACY_ASSESSMENT_UNAVAILABLE");
24
+ }
25
+ if (result.data.user) principal = { principal_id: result.data.user.id, email: result.data.user.email ?? null, email_confirmed_at: result.data.user.email_confirmed_at ?? null };
26
+ const read = await client.from("admin_users").select("id, membership_state, role, is_machine_actor").eq("id", principalId).maybeSingle();
27
+ if (read.error) throw new PrivacyRefusal("PRIVACY_ASSESSMENT_UNAVAILABLE");
28
+ membership = read.data ? JSON.parse(JSON.stringify(read.data)) as PrivacyJson : null;
29
+ }
30
+ if (!contact && !principal) return null;
31
+ return { contactId: contact?.id ?? null, principalId, identity: {
32
+ contact: contact ? JSON.parse(JSON.stringify(contact)) as PrivacyJson : null, principal, membership,
33
+ unavailable: ["contact.country", "identity.audit-history", "identity.invitation-history", "identity.erasure-receipts"],
34
+ } };
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,41 @@
1
+ type Role = "admin" | "distributor";
2
+ const roleName = (role: Role) => role === "admin" ? "Admin" : "Dystrybutor";
3
+ const en = {
4
+ unauthorized: "Unauthorized", invalidToken: "Invalid token",
5
+ inviteForbidden: "Only admins may invite users", emailRequired: "Email required",
6
+ invalidRole: "Invalid role", alreadyAdded: "This user is already added",
7
+ addFailed: (detail: string) => `Adding user failed: ${detail}`,
8
+ notificationFailed: (detail: string) => `Role added, but notification failed: ${detail}`,
9
+ roleAddedDisabled: (email: string, role: Role) => `User ${email} already had an account; added role ${role}, but notification was disabled and not sent.`,
10
+ roleAddedSuppressed: (email: string, role: Role) => `User ${email} already had an account; added role ${role}, but notification was suppressed and not sent.`,
11
+ roleAdded: (email: string, role: Role) => `User ${email} already had an account; added role ${role} and sent a notification.`,
12
+ inviteFailed: (detail: string) => `Invitation failed: ${detail}`,
13
+ roleInsertFailed: (detail: string) => `Account created, but adding role failed: ${detail}`,
14
+ invited: (email: string, role: Role) => `Invitation with role ${role} sent to ${email}`,
15
+ removeForbidden: "Only admins may revoke user access", userIdRequired: "User ID required",
16
+ selfRemoveForbidden: "You cannot revoke your own access",
17
+ lastAdmin: "At least one active human admin must remain.",
18
+ selfRevoke: "You cannot revoke your own access.",
19
+ machineRevoke: "Machine actors cannot be revoked through this route.",
20
+ adminNotFound: "Admin user not found.", adminRequired: "Admin role required.",
21
+ revokeFailed: (detail: string) => `Admin access revoke failed: ${detail}`,
22
+ };
23
+ export type IdentityMessages = typeof en;
24
+ export const identityMessages: Readonly<Record<"en" | "pl", IdentityMessages>> = {
25
+ en,
26
+ pl: {
27
+ ...en,
28
+ inviteForbidden: "Tylko admini mogą zapraszać użytkowników", emailRequired: "Brak emaila",
29
+ invalidRole: "Nieprawidłowa rola", alreadyAdded: "Ten użytkownik jest już dodany",
30
+ addFailed: (detail) => `Błąd dodawania: ${detail}`,
31
+ notificationFailed: (detail) => `Rola dodana, ale nie udało się wysłać powiadomienia: ${detail}`,
32
+ roleAddedDisabled: (email, role) => `Użytkownik ${email} już miał konto — dodano rolę ${roleName(role)}, ale powiadomienie było wyłączone i nie zostało wysłane.`,
33
+ roleAddedSuppressed: (email, role) => `Użytkownik ${email} już miał konto — dodano rolę ${roleName(role)}, ale wysłanie powiadomienia zostało zablokowane i nie zostało wysłane.`,
34
+ roleAdded: (email, role) => `Użytkownik ${email} już miał konto — dodano rolę ${roleName(role)} i wysłano powiadomienie.`,
35
+ inviteFailed: (detail) => `Błąd zaproszenia: ${detail}`,
36
+ roleInsertFailed: (detail) => `Konto utworzone, ale błąd dodawania roli: ${detail}`,
37
+ invited: (email, role) => `Zaproszenie z rolą ${roleName(role)} wysłane na ${email}`,
38
+ removeForbidden: "Tylko administratorzy mogą odbierać dostęp użytkownikom",
39
+ userIdRequired: "Brak ID użytkownika", selfRemoveForbidden: "Nie możesz odebrać sobie dostępu",
40
+ },
41
+ };
@@ -0,0 +1,229 @@
1
+ import type { IdentityJwtLibrary, IdentityJwtLibraryLoader, IdentityJwk } from "./jwtLibrary.js";
2
+ import type { IdentityVerifierPort, VerifiedPrincipal } from "./ports.js";
3
+ import type { OidcFailureCode } from "./contracts/oidc.js";
4
+ import { deriveOidcPrincipalId } from "./oidcPrincipal.js";
5
+
6
+ export interface OidcIdentityVerifierConfig {
7
+ issuer: string;
8
+ audience: string;
9
+ algorithms: readonly ("RS256" | "ES256")[];
10
+ maxTokenLifetimeSeconds: number;
11
+ requireSingleAudience?: boolean;
12
+ /** Conformance only: literal loopback addresses, never arbitrary HTTP hosts. */
13
+ allowLoopbackHttp?: boolean;
14
+ }
15
+ export type OidcVerificationResult = { ok: true; principal: VerifiedPrincipal; expiresAt: string }
16
+ | { ok: false; code: OidcFailureCode };
17
+ export interface OidcReadinessReport {
18
+ state: "ready" | "unknown" | "unsatisfied";
19
+ discovery: { reachable: boolean; valid: boolean; checkedAt: string | null; expiresAt: string | null };
20
+ jwks: { loaded: boolean; checkedAt: string | null; expiresAt: string | null };
21
+ code?: OidcFailureCode;
22
+ }
23
+ export interface OidcIdentityVerifier extends IdentityVerifierPort {
24
+ verifyToken(token: string): Promise<OidcVerificationResult>;
25
+ readiness(): Promise<OidcReadinessReport>;
26
+ }
27
+ class Refusal extends Error {
28
+ constructor(readonly code: OidcFailureCode) { super(code); }
29
+ }
30
+ const refuse = (code: OidcFailureCode): never => { throw new Refusal(code); };
31
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
32
+ const bounded = (value: unknown, max: number): value is string => typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= max && !Array.from(value).some(character => character.charCodeAt(0) <= 0x20 || character.charCodeAt(0) === 0x7f);
33
+ const stamp = (value: number | null) => value === null ? null : new Date(value).toISOString();
34
+ const unavailable = (code: OidcFailureCode) => code.endsWith("_UNAVAILABLE") || code === "OIDC_JWKS_REFRESH_RATE_LIMITED";
35
+
36
+ /** One binding per host/configuration. Imports and construction perform no I/O or JWT loading. */
37
+ export function createOidcIdentityVerifier(config: OidcIdentityVerifierConfig, dependencies: {
38
+ loadJwt: IdentityJwtLibraryLoader; fetch?: typeof globalThis.fetch; now?: () => number;
39
+ }): OidcIdentityVerifier {
40
+ const settings = { ...config, algorithms: [...config.algorithms] };
41
+ const now = dependencies.now ?? Date.now;
42
+ const transport = dependencies.fetch ?? globalThis.fetch;
43
+ function validUrl(value: string) {
44
+ const url = new URL(value);
45
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:"
46
+ && !(settings.allowLoopbackHttp && url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname)))) throw new Error();
47
+ return url;
48
+ }
49
+ let origin = "", configurationValid = true;
50
+ try {
51
+ if (!bounded(settings.issuer, 2048) || !bounded(settings.audience, 256)
52
+ || !settings.algorithms.length || settings.algorithms.some(alg => alg !== "RS256" && alg !== "ES256")
53
+ || !Number.isInteger(settings.maxTokenLifetimeSeconds) || settings.maxTokenLifetimeSeconds < 1 || settings.maxTokenLifetimeSeconds > 3600) throw new Error();
54
+ origin = validUrl(settings.issuer).origin;
55
+ } catch { configurationValid = false; }
56
+ let library: Promise<IdentityJwtLibrary> | undefined;
57
+ const jwt = () => library ??= Promise.resolve().then(dependencies.loadJwt).catch(() => { library = undefined; return refuse("OIDC_JWT_LIBRARY_UNAVAILABLE"); });
58
+ let discovery: { jwksUrl: string; expires: number } | undefined;
59
+ let keys: { values: IdentityJwk[]; expires: number } | undefined;
60
+ let discoveryCheck: number | null = null, keyCheck: number | null = null, lastKeyAttempt = -Infinity;
61
+ let reachable = false, discoveryValid = false;
62
+ let discoveryFlight: Promise<NonNullable<typeof discovery>> | undefined;
63
+ let keyFlight: Promise<NonNullable<typeof keys>> | undefined;
64
+ let keyFailure: OidcFailureCode | undefined;
65
+
66
+ async function document(url: string, kind: "DISCOVERY" | "JWKS", maxAge: number) {
67
+ const controller = new AbortController();
68
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
69
+ let timer: ReturnType<typeof setTimeout> | undefined;
70
+ const deadline = new Promise<never>((_, reject) => { timer = setTimeout(() => {
71
+ controller.abort(); void reader?.cancel().catch(() => {});
72
+ reject(new Refusal(`OIDC_${kind}_UNAVAILABLE`));
73
+ }, 3000); });
74
+ try {
75
+ return await Promise.race([deadline, (async () => {
76
+ const response = await transport(url, { redirect: "error", signal: controller.signal, headers: { accept: "application/json" } });
77
+ if (kind === "DISCOVERY") reachable = true;
78
+ if (!response.ok) return refuse(`OIDC_${kind}_UNAVAILABLE`);
79
+ if (response.redirected || (response.url && response.url !== url)) return refuse(`OIDC_${kind}_INVALID`);
80
+ const length = response.headers.get("content-length");
81
+ if (length && (!/^\d+$/.test(length) || Number(length) > 65536)) return refuse(`OIDC_${kind}_INVALID`);
82
+ if (!response.body) return refuse(`OIDC_${kind}_INVALID`);
83
+ reader = response.body.getReader();
84
+ const chunks: Uint8Array[] = []; let size = 0;
85
+ for (;;) {
86
+ const part = await reader.read(); if (part.done) break;
87
+ size += part.value.byteLength;
88
+ if (size > 65536) { void reader.cancel().catch(() => {}); return refuse(`OIDC_${kind}_INVALID`); }
89
+ chunks.push(part.value);
90
+ }
91
+ let value: unknown;
92
+ try { value = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return refuse(`OIDC_${kind}_INVALID`); }
93
+ const control = response.headers.get("cache-control") ?? "";
94
+ const ages = [...control.matchAll(/(?:^|,)\s*max-age\s*=\s*"?(\d+)"?/gi)].map(match => Number(match[1]));
95
+ const age = Number(response.headers.get("age") ?? "0");
96
+ // Revalidate no-cache documents at the next permitted JWKS refresh.
97
+ const cacheBound = /(?:^|,)\s*no-cache(?:\s|,|$)/i.test(control) ? 30 : maxAge;
98
+ const seconds = /(?:^|,)\s*no-store(?:\s|,|$)/i.test(control) ? 0
99
+ : Math.max(0, Math.min(maxAge, cacheBound, ...ages) - (Number.isFinite(age) && age >= 0 ? age : maxAge));
100
+ return { value, expires: now() + seconds * 1000 };
101
+ })()]);
102
+ } catch (error) {
103
+ if (error instanceof Refusal) throw error;
104
+ return refuse(`OIDC_${kind}_UNAVAILABLE`);
105
+ } finally { clearTimeout(timer); controller.abort(); }
106
+ }
107
+ async function metadata() {
108
+ if (!configurationValid) return refuse("OIDC_CONFIGURATION_INVALID");
109
+ if (discovery && discovery.expires > now()) return discovery;
110
+ if (discoveryFlight) return discoveryFlight;
111
+ discoveryFlight = (async () => {
112
+ discoveryCheck = now(); reachable = false; discoveryValid = false;
113
+ const fetched = await document(`${settings.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, "DISCOVERY", 3600);
114
+ if (!record(fetched.value) || fetched.value.issuer !== settings.issuer || typeof fetched.value.jwks_uri !== "string") return refuse("OIDC_DISCOVERY_INVALID");
115
+ try { if (validUrl(fetched.value.jwks_uri).origin !== origin) throw new Error(); }
116
+ catch { return refuse("OIDC_DISCOVERY_INVALID"); }
117
+ if (discovery?.jwksUrl !== fetched.value.jwks_uri) keys = undefined;
118
+ discoveryValid = true;
119
+ return discovery = { jwksUrl: fetched.value.jwks_uri, expires: fetched.expires };
120
+ })();
121
+ try { return await discoveryFlight; } finally { discoveryFlight = undefined; }
122
+ }
123
+ async function keySet(force = false) {
124
+ const meta = await metadata();
125
+ if (!force && keys && keys.expires > now()) return keys;
126
+ if (keyFlight) return keyFlight;
127
+ if (now() - lastKeyAttempt < 30000) return refuse("OIDC_JWKS_REFRESH_RATE_LIMITED");
128
+ lastKeyAttempt = now(); keyCheck = now();
129
+ keyFlight = (async () => {
130
+ const fetched = await document(meta.jwksUrl, "JWKS", 300);
131
+ const value = fetched.value;
132
+ if (!record(value) || !Array.isArray(value.keys) || !value.keys.length || value.keys.length > 64) return refuse("OIDC_JWKS_INVALID");
133
+ const eligible: IdentityJwk[] = [];
134
+ for (const key of value.keys) {
135
+ if (!record(key) || ["d", "p", "q", "dp", "dq", "qi", "oth", "k"].some(field => field in key)) return refuse("OIDC_JWKS_INVALID");
136
+ if ((key.use !== undefined && key.use !== "sig") || (key.key_ops !== undefined && (!Array.isArray(key.key_ops) || !key.key_ops.length || !key.key_ops.every(operation => operation === "verify")))) continue;
137
+ const alg = key.alg === undefined ? (key.kty === "RSA" ? "RS256" : key.kty === "EC" && key.crv === "P-256" ? "ES256" : "") : key.alg;
138
+ if (!settings.algorithms.includes(alg as "RS256" | "ES256")
139
+ || (alg === "RS256" ? key.kty !== "RSA" : key.kty !== "EC" || key.crv !== "P-256")) continue;
140
+ if (!bounded(key.kid, 128) || (alg === "RS256" ? !bounded(key.n, 2048) || !bounded(key.e, 16)
141
+ : !bounded(key.x, 128) || !bounded(key.y, 128))) return refuse("OIDC_JWKS_INVALID");
142
+ eligible.push({ ...key, alg: String(alg) });
143
+ }
144
+ if (!eligible.length || new Set(eligible.map(key => key.kid)).size !== eligible.length) return refuse("OIDC_JWKS_INVALID");
145
+ const lib = await jwt();
146
+ try { await Promise.all(eligible.map(key => lib.importJWK(key, key.alg!))); }
147
+ catch { return refuse("OIDC_JWKS_INVALID"); }
148
+ keyFailure = undefined;
149
+ return keys = { values: eligible, expires: fetched.expires };
150
+ })();
151
+ try { return await keyFlight; }
152
+ catch (error) { keyFailure = error instanceof Refusal ? error.code : "OIDC_JWKS_UNAVAILABLE"; throw error; }
153
+ finally { keyFlight = undefined; }
154
+ }
155
+ async function verifyToken(token: string): Promise<OidcVerificationResult> {
156
+ try {
157
+ if (!configurationValid) return refuse("OIDC_CONFIGURATION_INVALID");
158
+ if (typeof token !== "string" || Buffer.byteLength(token) > 16384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) return refuse("OIDC_TOKEN_INVALID");
159
+ let header: unknown, unverifiedPayload: unknown;
160
+ try {
161
+ const parts = token.split(".").map(part => {
162
+ const bytes = Buffer.from(part, "base64url");
163
+ if (bytes.toString("base64url") !== part) return refuse("OIDC_TOKEN_INVALID");
164
+ return bytes;
165
+ });
166
+ const decoder = new TextDecoder("utf-8", { fatal: true });
167
+ header = JSON.parse(decoder.decode(parts[0]!));
168
+ unverifiedPayload = JSON.parse(decoder.decode(parts[1]!));
169
+ } catch { return refuse("OIDC_TOKEN_INVALID"); }
170
+ if (!record(header) || !record(unverifiedPayload) || !bounded(header.kid, 128)
171
+ || !settings.algorithms.includes(header.alg as "RS256" | "ES256")) return refuse("OIDC_TOKEN_INVALID");
172
+ // Unverified claims may refuse before I/O; only verified claims can admit.
173
+ if (unverifiedPayload.iss !== settings.issuer) return refuse("OIDC_ISSUER_MISMATCH");
174
+ let set = await keySet();
175
+ const select = () => set.values.find(key => key.kid === header.kid && key.alg === header.alg);
176
+ if (!select()) { set = await keySet(true); if (!select()) return refuse("OIDC_KID_UNKNOWN"); }
177
+ const lib = await jwt();
178
+ const selected = select()!;
179
+ let payload;
180
+ try {
181
+ const key = await lib.importJWK(selected, String(header.alg));
182
+ ({ payload } = await lib.jwtVerify(token, async protectedHeader => {
183
+ if (protectedHeader.alg !== header.alg || protectedHeader.kid !== header.kid) return refuse("OIDC_TOKEN_INVALID");
184
+ return key;
185
+ }, { algorithms: settings.algorithms, audience: settings.audience, issuer: settings.issuer, currentDate: new Date(now()), clockTolerance: 0 }));
186
+ } catch (error) {
187
+ if (record(error)) {
188
+ if (error.code === "ERR_JWT_EXPIRED") return refuse("OIDC_TOKEN_EXPIRED");
189
+ if (error.code === "ERR_JWT_CLAIM_VALIDATION_FAILED") {
190
+ if (error.claim === "iss") return refuse("OIDC_ISSUER_MISMATCH");
191
+ if (error.claim === "aud") return refuse("OIDC_AUDIENCE_MISMATCH");
192
+ if (error.claim === "nbf") return refuse("OIDC_TOKEN_NOT_ACTIVE");
193
+ }
194
+ }
195
+ return refuse("OIDC_TOKEN_INVALID");
196
+ }
197
+ if (payload.iss !== settings.issuer) return refuse("OIDC_ISSUER_MISMATCH");
198
+ if (settings.requireSingleAudience ? payload.aud !== settings.audience
199
+ : !(payload.aud === settings.audience || (Array.isArray(payload.aud) && payload.aud.every(aud => typeof aud === "string") && payload.aud.includes(settings.audience)))) return refuse("OIDC_AUDIENCE_MISMATCH");
200
+ const seconds = now() / 1000;
201
+ if (!Number.isSafeInteger(payload.iat) || !Number.isSafeInteger(payload.exp) || payload.exp! <= payload.iat!
202
+ || payload.exp! - payload.iat! > settings.maxTokenLifetimeSeconds || payload.iat! > seconds) return refuse("OIDC_TOKEN_LIFETIME_INVALID");
203
+ if (payload.exp! <= seconds) return refuse("OIDC_TOKEN_EXPIRED");
204
+ if (payload.nbf !== undefined && (!Number.isSafeInteger(payload.nbf) || payload.nbf > seconds)) return refuse("OIDC_TOKEN_NOT_ACTIVE");
205
+ if (!bounded(payload.sub, 1024)) return refuse("OIDC_SUBJECT_INVALID");
206
+ if (!bounded(payload.email, 254) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email)) return refuse("OIDC_EMAIL_INVALID");
207
+ if (payload.email_verified !== true) return refuse("OIDC_EMAIL_UNVERIFIED");
208
+ return { ok: true, principal: { principalId: deriveOidcPrincipalId(settings.issuer, payload.sub), email: payload.email.trim().toLowerCase(), emailVerified: true }, expiresAt: stamp(payload.exp! * 1000)! };
209
+ } catch (error) { return { ok: false, code: error instanceof Refusal ? error.code : "OIDC_TOKEN_INVALID" }; }
210
+ }
211
+ return {
212
+ verifyToken,
213
+ async verifyAccessToken(token) {
214
+ const result = await verifyToken(token);
215
+ if (result.ok) return result.principal;
216
+ if (unavailable(result.code)) throw new Error("OIDC identity verification unavailable");
217
+ return null;
218
+ },
219
+ async readiness() {
220
+ let code: OidcFailureCode | undefined;
221
+ try { await keySet(); } catch (error) { code = error instanceof Refusal ? error.code : "OIDC_JWKS_UNAVAILABLE"; }
222
+ if (code === "OIDC_JWKS_REFRESH_RATE_LIMITED" && keyFailure) code = keyFailure;
223
+ const fresh = !!keys && keys.expires > now();
224
+ return { state: code ? unavailable(code) ? "unknown" : "unsatisfied" : fresh ? "ready" : "unsatisfied",
225
+ discovery: { reachable, valid: discoveryValid && !!discovery && discovery.expires > now(), checkedAt: stamp(discoveryCheck), expiresAt: stamp(discovery?.expires ?? null) },
226
+ jwks: { loaded: fresh, checkedAt: stamp(keyCheck), expiresAt: stamp(keys?.expires ?? null) }, ...(code ? { code } : {}) };
227
+ },
228
+ };
229
+ }