@edcalderon/auth 1.2.2 → 1.4.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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Provisioning adapters for Authentik ↔ application user sync.
3
+ *
4
+ * This module provides pluggable adapters that run **server-side** after
5
+ * a successful Authentik callback to ensure the authenticated user exists
6
+ * in the application's local user store.
7
+ *
8
+ * Adapters are **idempotent** and **fail-closed**: if sync fails the
9
+ * callback handler must not redirect the user into the protected app.
10
+ *
11
+ * Reference:
12
+ * - CIG apps/dashboard/app/api/auth/sync/route.ts
13
+ * - CIG apps/dashboard/lib/authSync.ts
14
+ */
15
+ import type { ProvisioningAdapter, ProvisioningPayload, ProvisioningResult, SupabaseSyncConfig } from "./types";
16
+ /**
17
+ * A no-op provisioning adapter that always succeeds.
18
+ *
19
+ * Use this when the app does not need post-login user sync.
20
+ */
21
+ export declare class NoopProvisioningAdapter implements ProvisioningAdapter {
22
+ sync(_payload: ProvisioningPayload): Promise<ProvisioningResult>;
23
+ }
24
+ /**
25
+ * Create a provisioning adapter from a plain function.
26
+ *
27
+ * ```ts
28
+ * const adapter = createProvisioningAdapter(async (payload) => {
29
+ * await myDb.upsertUser(payload);
30
+ * return { synced: true, appUserId: "..." };
31
+ * });
32
+ * ```
33
+ */
34
+ export declare function createProvisioningAdapter(syncFn: (payload: ProvisioningPayload) => Promise<ProvisioningResult>): ProvisioningAdapter;
35
+ /**
36
+ * Normalise a provisioning payload for Supabase sync.
37
+ * - Lowercases email
38
+ * - Resolves name from multiple claim fields
39
+ * - Applies default issuer and provider
40
+ */
41
+ export declare function normalizePayload(payload: ProvisioningPayload, defaultIssuer?: string): ProvisioningPayload;
42
+ /**
43
+ * SupabaseClient interface — minimal subset of `@supabase/supabase-js`
44
+ * needed for the sync adapter. This avoids a hard dependency.
45
+ */
46
+ interface SupabaseAdminClient {
47
+ auth: {
48
+ admin: {
49
+ listUsers(params?: {
50
+ page?: number;
51
+ perPage?: number;
52
+ }): Promise<{
53
+ data: {
54
+ users: SupabaseAuthUser[];
55
+ };
56
+ error: SupabaseError | null;
57
+ }>;
58
+ createUser(params: Record<string, unknown>): Promise<{
59
+ data: {
60
+ user: SupabaseAuthUser | null;
61
+ };
62
+ error: SupabaseError | null;
63
+ }>;
64
+ updateUserById(id: string, params: Record<string, unknown>): Promise<{
65
+ data: {
66
+ user: SupabaseAuthUser | null;
67
+ };
68
+ error: SupabaseError | null;
69
+ }>;
70
+ deleteUser(id: string): Promise<{
71
+ error: SupabaseError | null;
72
+ }>;
73
+ };
74
+ };
75
+ rpc(fn: string, params: Record<string, unknown>): Promise<{
76
+ data: unknown;
77
+ error: SupabaseError | null;
78
+ }>;
79
+ }
80
+ interface SupabaseAuthUser {
81
+ id: string;
82
+ email?: string;
83
+ app_metadata?: Record<string, unknown>;
84
+ user_metadata?: Record<string, unknown>;
85
+ }
86
+ interface SupabaseError {
87
+ message: string;
88
+ code?: string;
89
+ }
90
+ /**
91
+ * Authentik ↔ Supabase integrated sync adapter.
92
+ *
93
+ * This adapter implements the full CIG-proven sync flow:
94
+ *
95
+ * 1. Normalise the OIDC payload
96
+ * 2. Ensure a shadow auth.users record (identity-first matching)
97
+ * 3. Call the `upsert_oidc_user` RPC to sync into public.users
98
+ * 4. Roll back the shadow auth.users record if the RPC fails
99
+ *
100
+ * The adapter requires a `SupabaseClient` created with `service_role` key.
101
+ */
102
+ export declare class SupabaseSyncAdapter implements ProvisioningAdapter {
103
+ private config;
104
+ private client;
105
+ constructor(client: SupabaseAdminClient, config: SupabaseSyncConfig);
106
+ sync(payload: ProvisioningPayload): Promise<ProvisioningResult>;
107
+ }
108
+ /**
109
+ * Create a Supabase sync adapter from a client and config.
110
+ *
111
+ * This is a convenience factory that avoids direct `new SupabaseSyncAdapter(...)`.
112
+ *
113
+ * ```ts
114
+ * import { createClient } from "@supabase/supabase-js";
115
+ *
116
+ * const supabase = createClient(url, serviceRoleKey);
117
+ * const adapter = createSupabaseSyncAdapter(supabase, {
118
+ * supabaseUrl: url,
119
+ * supabaseServiceRoleKey: serviceRoleKey,
120
+ * });
121
+ * ```
122
+ */
123
+ export declare function createSupabaseSyncAdapter(client: SupabaseAdminClient, config: SupabaseSyncConfig): SupabaseSyncAdapter;
124
+ export {};
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Provisioning adapters for Authentik ↔ application user sync.
3
+ *
4
+ * This module provides pluggable adapters that run **server-side** after
5
+ * a successful Authentik callback to ensure the authenticated user exists
6
+ * in the application's local user store.
7
+ *
8
+ * Adapters are **idempotent** and **fail-closed**: if sync fails the
9
+ * callback handler must not redirect the user into the protected app.
10
+ *
11
+ * Reference:
12
+ * - CIG apps/dashboard/app/api/auth/sync/route.ts
13
+ * - CIG apps/dashboard/lib/authSync.ts
14
+ */
15
+ /* ------------------------------------------------------------------ */
16
+ /* No-op adapter */
17
+ /* ------------------------------------------------------------------ */
18
+ /**
19
+ * A no-op provisioning adapter that always succeeds.
20
+ *
21
+ * Use this when the app does not need post-login user sync.
22
+ */
23
+ export class NoopProvisioningAdapter {
24
+ async sync(_payload) {
25
+ return { synced: true };
26
+ }
27
+ }
28
+ /* ------------------------------------------------------------------ */
29
+ /* Custom adapter (function-based) */
30
+ /* ------------------------------------------------------------------ */
31
+ /**
32
+ * Create a provisioning adapter from a plain function.
33
+ *
34
+ * ```ts
35
+ * const adapter = createProvisioningAdapter(async (payload) => {
36
+ * await myDb.upsertUser(payload);
37
+ * return { synced: true, appUserId: "..." };
38
+ * });
39
+ * ```
40
+ */
41
+ export function createProvisioningAdapter(syncFn) {
42
+ return { sync: syncFn };
43
+ }
44
+ /* ------------------------------------------------------------------ */
45
+ /* Supabase sync adapter */
46
+ /* ------------------------------------------------------------------ */
47
+ /**
48
+ * Normalise a provisioning payload for Supabase sync.
49
+ * - Lowercases email
50
+ * - Resolves name from multiple claim fields
51
+ * - Applies default issuer and provider
52
+ */
53
+ export function normalizePayload(payload, defaultIssuer) {
54
+ const rawClaims = payload.rawClaims || {};
55
+ const name = payload.name ||
56
+ rawClaims.preferred_username ||
57
+ rawClaims.name ||
58
+ payload.email?.split("@")[0] ||
59
+ "";
60
+ return {
61
+ ...payload,
62
+ email: (payload.email || "").toLowerCase().trim(),
63
+ name: name.trim() || undefined,
64
+ iss: payload.iss || defaultIssuer || "",
65
+ provider: payload.provider || "authentik",
66
+ };
67
+ }
68
+ /**
69
+ * Build the user_metadata and app_metadata objects for a Supabase
70
+ * auth.users shadow record.
71
+ */
72
+ function buildShadowMetadata(payload) {
73
+ return {
74
+ user_metadata: {
75
+ name: payload.name,
76
+ full_name: payload.name,
77
+ avatar_url: payload.picture,
78
+ oidc_sub: payload.sub,
79
+ oidc_issuer: payload.iss,
80
+ upstream_provider: payload.provider,
81
+ },
82
+ app_metadata: {
83
+ provider: "authentik",
84
+ auth_source: "authentik",
85
+ oidc_sub: payload.sub,
86
+ oidc_issuer: payload.iss,
87
+ upstream_provider: payload.provider,
88
+ },
89
+ };
90
+ }
91
+ /**
92
+ * Find an existing shadow auth.users record by OIDC identity or email.
93
+ *
94
+ * Matching strategy (identity-first, per CIG production rules):
95
+ * 1. Match by (oidc_sub + oidc_issuer) in app_metadata
96
+ * 2. Fall back to email only when reusing an existing shadow user
97
+ *
98
+ * Paginates through all auth.users pages so that matches beyond the first
99
+ * page are not missed in larger Supabase projects.
100
+ */
101
+ async function findShadowAuthUser(client, payload) {
102
+ const perPage = 1000;
103
+ let page = 1;
104
+ let emailMatch = null;
105
+ // Paginate through all auth.users
106
+ while (true) {
107
+ const { data, error } = await client.auth.admin.listUsers({ page, perPage });
108
+ if (error) {
109
+ throw new Error(`Failed to list auth users: ${error.message}`);
110
+ }
111
+ const users = data.users;
112
+ // 1. Identity-first match
113
+ const identityMatch = users.find((u) => {
114
+ const meta = u.app_metadata || {};
115
+ return (meta.auth_source === "authentik" &&
116
+ meta.oidc_sub === payload.sub &&
117
+ meta.oidc_issuer === payload.iss);
118
+ });
119
+ if (identityMatch) {
120
+ return { user: identityMatch, matchedBy: "identity" };
121
+ }
122
+ // 2. Accumulate email fallback (first match wins across pages)
123
+ if (!emailMatch && payload.email) {
124
+ const match = users.find((u) => u.email?.toLowerCase() === payload.email.toLowerCase());
125
+ if (match) {
126
+ emailMatch = match;
127
+ }
128
+ }
129
+ // No more pages
130
+ if (users.length < perPage) {
131
+ break;
132
+ }
133
+ page++;
134
+ }
135
+ if (emailMatch) {
136
+ return { user: emailMatch, matchedBy: "email" };
137
+ }
138
+ return { user: null, matchedBy: null };
139
+ }
140
+ /**
141
+ * Ensure a shadow auth.users record exists and is up-to-date.
142
+ *
143
+ * Returns the auth user ID and whether the record was newly created
144
+ * (important for rollback on downstream failure).
145
+ */
146
+ async function ensureShadowAuthUser(client, payload) {
147
+ const { user: existing, matchedBy } = await findShadowAuthUser(client, payload);
148
+ const metadata = buildShadowMetadata(payload);
149
+ if (existing) {
150
+ // Check if metadata needs updating
151
+ const currentMeta = existing.app_metadata || {};
152
+ const needsUpdate = currentMeta.oidc_sub !== payload.sub ||
153
+ currentMeta.oidc_issuer !== payload.iss ||
154
+ (existing.user_metadata || {}).avatar_url !== payload.picture ||
155
+ (existing.user_metadata || {}).name !== payload.name;
156
+ if (needsUpdate || matchedBy === "email") {
157
+ const { error } = await client.auth.admin.updateUserById(existing.id, {
158
+ email: payload.email || existing.email,
159
+ email_confirm: payload.emailVerified ?? false,
160
+ ...metadata,
161
+ });
162
+ if (error) {
163
+ throw new Error(`Failed to update shadow auth user: ${error.message}`);
164
+ }
165
+ return { authUserId: existing.id, created: false, updated: true };
166
+ }
167
+ return { authUserId: existing.id, created: false, updated: false };
168
+ }
169
+ // Create new shadow user
170
+ const { data, error } = await client.auth.admin.createUser({
171
+ email: payload.email,
172
+ email_confirm: payload.emailVerified ?? false,
173
+ role: "authenticated",
174
+ ...metadata,
175
+ });
176
+ if (error) {
177
+ throw new Error(`Failed to create shadow auth user: ${error.message}`);
178
+ }
179
+ if (!data.user) {
180
+ throw new Error("Shadow auth user creation returned no user");
181
+ }
182
+ return { authUserId: data.user.id, created: true, updated: false };
183
+ }
184
+ /**
185
+ * Authentik ↔ Supabase integrated sync adapter.
186
+ *
187
+ * This adapter implements the full CIG-proven sync flow:
188
+ *
189
+ * 1. Normalise the OIDC payload
190
+ * 2. Ensure a shadow auth.users record (identity-first matching)
191
+ * 3. Call the `upsert_oidc_user` RPC to sync into public.users
192
+ * 4. Roll back the shadow auth.users record if the RPC fails
193
+ *
194
+ * The adapter requires a `SupabaseClient` created with `service_role` key.
195
+ */
196
+ export class SupabaseSyncAdapter {
197
+ config;
198
+ client;
199
+ constructor(client, config) {
200
+ this.client = client;
201
+ this.config = config;
202
+ }
203
+ async sync(payload) {
204
+ const normalized = normalizePayload(payload, this.config.defaultIssuer);
205
+ if (!normalized.sub || !normalized.iss) {
206
+ return {
207
+ synced: false,
208
+ error: "Missing required sub or iss in provisioning payload",
209
+ errorCode: "invalid_payload",
210
+ };
211
+ }
212
+ let shadowResult = null;
213
+ // Step 1: Shadow auth.users (optional, default: true)
214
+ if (this.config.createShadowAuthUser !== false) {
215
+ try {
216
+ shadowResult = await ensureShadowAuthUser(this.client, normalized);
217
+ }
218
+ catch (err) {
219
+ return {
220
+ synced: false,
221
+ error: err instanceof Error ? err.message : "Shadow auth user sync failed",
222
+ errorCode: "shadow_auth_failed",
223
+ };
224
+ }
225
+ }
226
+ // Step 2: public.users upsert via RPC
227
+ const rpcName = this.config.upsertRpcName || "upsert_oidc_user";
228
+ const rpcParams = {
229
+ p_sub: normalized.sub,
230
+ p_iss: normalized.iss,
231
+ p_email: normalized.email || null,
232
+ p_email_verified: normalized.emailVerified ?? false,
233
+ p_name: normalized.name || null,
234
+ p_picture: normalized.picture || null,
235
+ p_provider: normalized.provider || null,
236
+ p_raw_claims: {
237
+ ...(normalized.rawClaims || {}),
238
+ ...(shadowResult
239
+ ? {
240
+ shadow_supabase_auth_user_id: shadowResult.authUserId,
241
+ shadow_supabase_auth_user_created: shadowResult.created,
242
+ shadow_supabase_auth_user_updated: shadowResult.updated,
243
+ }
244
+ : {}),
245
+ },
246
+ };
247
+ const { error: rpcError } = await this.client.rpc(rpcName, rpcParams);
248
+ if (rpcError) {
249
+ // Rollback: delete newly created shadow auth.users row
250
+ if (shadowResult?.created &&
251
+ this.config.rollbackOnFailure !== false) {
252
+ try {
253
+ await this.client.auth.admin.deleteUser(shadowResult.authUserId);
254
+ }
255
+ catch {
256
+ // Best-effort rollback — log but don't mask the original error
257
+ }
258
+ }
259
+ return {
260
+ synced: false,
261
+ authUserId: shadowResult?.authUserId,
262
+ authUserCreated: shadowResult?.created,
263
+ error: `RPC ${rpcName} failed: ${rpcError.message}`,
264
+ errorCode: "rpc_upsert_failed",
265
+ };
266
+ }
267
+ // Step 3: Link shadow auth.users ID to public.users row
268
+ if (shadowResult) {
269
+ const linkRpcName = this.config.linkShadowRpcName || "link_shadow_auth_user";
270
+ try {
271
+ const { error: linkError } = await this.client.rpc(linkRpcName, {
272
+ p_sub: normalized.sub,
273
+ p_iss: normalized.iss,
274
+ p_shadow_auth_user_id: shadowResult.authUserId,
275
+ });
276
+ if (linkError) {
277
+ // Rollback: delete newly created shadow auth.users row
278
+ if (shadowResult.created &&
279
+ this.config.rollbackOnFailure !== false) {
280
+ try {
281
+ await this.client.auth.admin.deleteUser(shadowResult.authUserId);
282
+ }
283
+ catch {
284
+ // Best-effort rollback
285
+ }
286
+ }
287
+ return {
288
+ synced: false,
289
+ authUserId: shadowResult.authUserId,
290
+ authUserCreated: shadowResult.created,
291
+ error: `${linkRpcName} failed: ${linkError.message}`,
292
+ errorCode: "shadow_link_failed",
293
+ };
294
+ }
295
+ }
296
+ catch (err) {
297
+ // Rollback: delete newly created shadow auth.users row
298
+ if (shadowResult.created &&
299
+ this.config.rollbackOnFailure !== false) {
300
+ try {
301
+ await this.client.auth.admin.deleteUser(shadowResult.authUserId);
302
+ }
303
+ catch {
304
+ // Best-effort rollback
305
+ }
306
+ }
307
+ return {
308
+ synced: false,
309
+ authUserId: shadowResult.authUserId,
310
+ authUserCreated: shadowResult.created,
311
+ error: err instanceof Error ? err.message : `${linkRpcName} failed`,
312
+ errorCode: "shadow_link_failed",
313
+ };
314
+ }
315
+ }
316
+ return {
317
+ synced: true,
318
+ authUserId: shadowResult?.authUserId,
319
+ authUserCreated: shadowResult?.created,
320
+ authUserUpdated: shadowResult?.updated,
321
+ };
322
+ }
323
+ }
324
+ /**
325
+ * Create a Supabase sync adapter from a client and config.
326
+ *
327
+ * This is a convenience factory that avoids direct `new SupabaseSyncAdapter(...)`.
328
+ *
329
+ * ```ts
330
+ * import { createClient } from "@supabase/supabase-js";
331
+ *
332
+ * const supabase = createClient(url, serviceRoleKey);
333
+ * const adapter = createSupabaseSyncAdapter(supabase, {
334
+ * supabaseUrl: url,
335
+ * supabaseServiceRoleKey: serviceRoleKey,
336
+ * });
337
+ * ```
338
+ */
339
+ export function createSupabaseSyncAdapter(client, config) {
340
+ return new SupabaseSyncAdapter(client, config);
341
+ }
342
+ //# sourceMappingURL=provisioning.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Safe redirect resolver.
3
+ *
4
+ * Prevents open-redirect vulnerabilities by validating that the target URL
5
+ * is within one of the allowed origins.
6
+ *
7
+ * Reference: CIG callback + logout redirect patterns.
8
+ */
9
+ import type { SafeRedirectConfig } from "./types";
10
+ /**
11
+ * Resolve a redirect URL, falling back to `fallbackUrl` if the target
12
+ * is not within one of the allowed origins.
13
+ *
14
+ * Rules:
15
+ * - Relative paths (e.g. "/dashboard") are always allowed
16
+ * - Absolute URLs must have an origin in `allowedOrigins`
17
+ * - Invalid URLs fall back to fallbackUrl
18
+ * - Empty / null targets fall back to fallbackUrl
19
+ */
20
+ export declare function resolveSafeRedirect(target: string | null | undefined, config: SafeRedirectConfig): string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Safe redirect resolver.
3
+ *
4
+ * Prevents open-redirect vulnerabilities by validating that the target URL
5
+ * is within one of the allowed origins.
6
+ *
7
+ * Reference: CIG callback + logout redirect patterns.
8
+ */
9
+ /**
10
+ * Resolve a redirect URL, falling back to `fallbackUrl` if the target
11
+ * is not within one of the allowed origins.
12
+ *
13
+ * Rules:
14
+ * - Relative paths (e.g. "/dashboard") are always allowed
15
+ * - Absolute URLs must have an origin in `allowedOrigins`
16
+ * - Invalid URLs fall back to fallbackUrl
17
+ * - Empty / null targets fall back to fallbackUrl
18
+ */
19
+ export function resolveSafeRedirect(target, config) {
20
+ if (!target || !target.trim()) {
21
+ return config.fallbackUrl;
22
+ }
23
+ const trimmed = target.trim();
24
+ // Relative paths are always safe
25
+ if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
26
+ return trimmed;
27
+ }
28
+ // Validate absolute URLs
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(trimmed);
32
+ }
33
+ catch {
34
+ return config.fallbackUrl;
35
+ }
36
+ // Check protocol
37
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
38
+ return config.fallbackUrl;
39
+ }
40
+ // Check origin — strip trailing slashes without regex (avoids CodeQL polynomial-regex flag)
41
+ const normalised = config.allowedOrigins.map((o) => {
42
+ let s = o;
43
+ while (s.endsWith("/"))
44
+ s = s.slice(0, -1);
45
+ return s;
46
+ });
47
+ if (normalised.includes(parsed.origin)) {
48
+ return trimmed;
49
+ }
50
+ return config.fallbackUrl;
51
+ }
52
+ //# sourceMappingURL=redirect.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Cross-origin PKCE relay handler for Authentik social login.
3
+ *
4
+ * When the login UI lives on a different origin than the callback handler,
5
+ * sessionStorage is origin-scoped. The relay stores the PKCE verifier and
6
+ * state on the callback origin before navigating to Authentik.
7
+ *
8
+ * Reference: CIG apps/dashboard/app/auth/login/[provider]/route.ts
9
+ */
10
+ import type { AuthentikRelayConfig, RelayIncomingParams, RelayHandlerResult } from "./types";
11
+ /**
12
+ * Generate the minimal HTML page that the relay route should serve.
13
+ *
14
+ * This page:
15
+ * 1. Stores PKCE params in the callback origin's sessionStorage
16
+ * 2. Redirects the browser to the Authentik social login flow
17
+ *
18
+ * The HTML is self-contained and does **not** load any external scripts.
19
+ */
20
+ export declare function createRelayPageHtml(config: AuthentikRelayConfig, params: RelayIncomingParams): RelayHandlerResult;
21
+ /**
22
+ * Parse the query parameters that the login origin sends to the relay.
23
+ *
24
+ * Expected query params:
25
+ * - `code_verifier` — PKCE verifier generated on the login origin
26
+ * - `code_challenge` — PKCE challenge (SHA-256 of verifier, base64url)
27
+ * - `state` — CSRF state token
28
+ * - `next` — (optional) post-login redirect target
29
+ *
30
+ * Returns `null` if required params are missing.
31
+ */
32
+ export declare function parseRelayParams(searchParams: URLSearchParams | Record<string, string | undefined>): RelayIncomingParams | null;
33
+ /**
34
+ * Read PKCE params back from sessionStorage on the callback origin.
35
+ *
36
+ * This is called by the callback handler after Authentik redirects back
37
+ * with `?code=&state=`.
38
+ */
39
+ export declare function readRelayStorage(storage: Storage, prefix?: string): {
40
+ codeVerifier: string;
41
+ state: string;
42
+ provider: string;
43
+ next?: string;
44
+ } | null;
45
+ /**
46
+ * Clean up relay storage after a successful callback exchange.
47
+ */
48
+ export declare function clearRelayStorage(storage: Storage, prefix?: string): void;