@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.
- package/CHANGELOG.md +23 -0
- package/README.md +73 -7
- package/dist/AuthentikOidcClient.d.ts +58 -0
- package/dist/AuthentikOidcClient.js +284 -0
- package/dist/authentik/callback.d.ts +71 -0
- package/dist/authentik/callback.js +163 -0
- package/dist/authentik/config.d.ts +53 -0
- package/dist/authentik/config.js +169 -0
- package/dist/authentik/index.d.ts +17 -0
- package/dist/authentik/index.js +22 -0
- package/dist/authentik/logout.d.ts +50 -0
- package/dist/authentik/logout.js +96 -0
- package/dist/authentik/provisioning.d.ts +124 -0
- package/dist/authentik/provisioning.js +342 -0
- package/dist/authentik/redirect.d.ts +20 -0
- package/dist/authentik/redirect.js +52 -0
- package/dist/authentik/relay.d.ts +48 -0
- package/dist/authentik/relay.js +146 -0
- package/dist/authentik/types.d.ts +264 -0
- package/dist/authentik/types.js +8 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +19 -1
- package/supabase/migrations/003_authentik_shadow_auth_users.sql +81 -0
|
@@ -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;
|