@edcalderon/auth 1.2.2 → 1.3.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-03-19
4
+
5
+ ### Added
6
+
7
+ - Added canonical `AuthentikOidcClient` browser helpers with PKCE-only OAuth flow utilities (`isAuthentikConfigured`, `startAuthentikOAuthFlow`, `handleAuthentikCallback`, `readOidcSession`, `clearOidcSession`, `hasPendingAuthentikCallback`, `OIDC_INITIAL_SEARCH`).
8
+ - Added exported Authentik OIDC types: `OidcClaims`, `OidcSession`, `OidcProvider`.
9
+ - Added README guidance for Authentik setup and the known Authentik `2026.2.1` social re-link bug workaround.
10
+
3
11
  ## [1.2.2] - 2026-03-19
4
12
 
5
13
  ### Added
package/README.md CHANGED
@@ -11,16 +11,13 @@ Swap between Supabase, Firebase, Hybrid, or any custom provider without changing
11
11
 
12
12
  ---
13
13
 
14
- ## 📋 Latest Changes (v1.2.2)
14
+ ## 📋 Latest Changes (v1.3.0)
15
15
 
16
16
  ### Added
17
17
 
18
- - Added `packages/auth/supabase/` SQL templates for a vendor-independent `public.users` table and optional Supabase Auth sync trigger.
19
-
20
- ### Fixed
21
-
22
- - Hardened the OIDC upsert migration so identity writes require a trusted server-side caller instead of the `anon` role.
23
- - Preserved existing user profile fields when optional claims are omitted during upserts or Supabase sync updates.
18
+ - Added canonical `AuthentikOidcClient` browser helpers with PKCE-only OAuth flow utilities (`isAuthentikConfigured`, `startAuthentikOAuthFlow`, `handleAuthentikCallback`, `readOidcSession`, `clearOidcSession`, `hasPendingAuthentikCallback`, `OIDC_INITIAL_SEARCH`).
19
+ - Added exported Authentik OIDC types: `OidcClaims`, `OidcSession`, `OidcProvider`.
20
+ - Added README guidance for Authentik setup and the known Authentik `2026.2.1` social re-link bug workaround.
24
21
 
25
22
  For full version history, see [CHANGELOG.md](./CHANGELOG.md) and [GitHub releases](https://github.com/edcalderon/my-second-brain/releases)
26
23
 
@@ -79,6 +76,68 @@ If you want an application-owned user table instead of coupling your identity mo
79
76
  - `001_create_app_users.sql`: vendor-independent `public.users` table plus secure server-side OIDC upsert RPC
80
77
  - `002_sync_auth_users_to_app_users.sql`: optional trigger and backfill for projects using Supabase Auth
81
78
 
79
+ ### Authentik OIDC Client (Canonical)
80
+
81
+ `@edcalderon/auth` exports a browser-first Authentik OIDC helper that is decoupled from Supabase and can be used with any backend session strategy.
82
+
83
+ ```ts
84
+ import {
85
+ isAuthentikConfigured,
86
+ startAuthentikOAuthFlow,
87
+ handleAuthentikCallback,
88
+ readOidcSession,
89
+ clearOidcSession,
90
+ hasPendingAuthentikCallback,
91
+ } from "@edcalderon/auth";
92
+
93
+ if (isAuthentikConfigured()) {
94
+ await startAuthentikOAuthFlow("google", {
95
+ providerSourceSlugs: {
96
+ google: "google",
97
+ discord: "discord",
98
+ },
99
+ });
100
+ }
101
+
102
+ if (hasPendingAuthentikCallback(window.location.search)) {
103
+ const session = await handleAuthentikCallback(window.location.search, {
104
+ onSessionReady: async (claims, tokens) => {
105
+ // Optional hook for API upsert/session handoff.
106
+ console.log(claims.sub, tokens.accessToken);
107
+ },
108
+ });
109
+
110
+ console.log("OIDC session", session);
111
+ }
112
+
113
+ const existing = readOidcSession();
114
+ if (!existing) {
115
+ clearOidcSession();
116
+ }
117
+ ```
118
+
119
+ Required env vars (defaults):
120
+
121
+ | Var | Description |
122
+ | --- | --- |
123
+ | `EXPO_PUBLIC_AUTHENTIK_ISSUER` | `https://<host>/application/o/<app-slug>/` |
124
+ | `EXPO_PUBLIC_AUTHENTIK_CLIENT_ID` | OAuth2 provider client ID |
125
+ | `EXPO_PUBLIC_AUTHENTIK_REDIRECT_URI` | App redirect URI registered in Authentik |
126
+
127
+ You can override env key names with `envKeys` and pass direct values with `issuer`, `clientId`, and `redirectUri`.
128
+
129
+ Authentik setup checklist:
130
+
131
+ 1. Configure an OAuth2/OIDC provider in Authentik with PKCE enabled.
132
+ 2. Ensure redirect URIs match your app origin/path exactly.
133
+ 3. Configure source login slugs (`providerSourceSlugs`) for each social provider.
134
+ 4. Use `onSessionReady` to hand off claims/tokens to your backend session flow.
135
+
136
+ Known Authentik `2026.2.1` bug workaround:
137
+
138
+ - A production hot-patch may be needed in Authentik `flow_manager.py` around `handle_existing_link` to avoid duplicate `(user_id, source_id)` writes when re-linking existing social identities.
139
+ - Track the upstream Authentik issue and re-apply the patch after container upgrades until a fixed release is available.
140
+
82
141
  ---
83
142
 
84
143
  ## Subpath Exports (Crucial for RN/Next.js compatibility)
@@ -0,0 +1,58 @@
1
+ export type OidcProvider = "google" | "discord" | string;
2
+ export interface OidcClaims {
3
+ sub: string;
4
+ iss: string;
5
+ email?: string;
6
+ email_verified?: boolean;
7
+ name?: string;
8
+ picture?: string;
9
+ preferred_username?: string;
10
+ [key: string]: unknown;
11
+ }
12
+ export interface OidcTokens {
13
+ accessToken: string;
14
+ tokenType?: string;
15
+ refreshToken?: string;
16
+ idToken?: string;
17
+ expiresIn?: number;
18
+ expiresAt?: number;
19
+ scope?: string;
20
+ }
21
+ export interface OidcSession {
22
+ provider: OidcProvider;
23
+ issuer: string;
24
+ clientId: string;
25
+ claims: OidcClaims;
26
+ tokens: OidcTokens;
27
+ createdAt: number;
28
+ }
29
+ export interface AuthentikEnvKeys {
30
+ issuer: string;
31
+ clientId: string;
32
+ redirectUri: string;
33
+ }
34
+ export interface AuthentikOidcConfig {
35
+ issuer?: string;
36
+ clientId?: string;
37
+ redirectUri?: string;
38
+ scope?: string;
39
+ env?: Record<string, string | undefined>;
40
+ envKeys?: Partial<AuthentikEnvKeys>;
41
+ providerSourceSlugs?: Record<string, string>;
42
+ authorizePath?: string;
43
+ tokenPath?: string;
44
+ userinfoPath?: string;
45
+ onSessionReady?: (claims: OidcClaims, tokens: OidcTokens, session: OidcSession) => void | Promise<void>;
46
+ storageKey?: string;
47
+ pendingStorageKey?: string;
48
+ sessionStorage?: Storage;
49
+ localStorage?: Storage;
50
+ fetchFn?: typeof fetch;
51
+ }
52
+ export declare const OIDC_INITIAL_SEARCH = "authentik:oidc:initial-search";
53
+ export declare function isAuthentikConfigured(config?: AuthentikOidcConfig): boolean;
54
+ export declare function hasPendingAuthentikCallback(searchString?: string): boolean;
55
+ export declare function readOidcSession(config?: AuthentikOidcConfig): OidcSession | null;
56
+ export declare function clearOidcSession(config?: AuthentikOidcConfig): void;
57
+ export declare function startAuthentikOAuthFlow(provider: OidcProvider, config?: AuthentikOidcConfig): Promise<void>;
58
+ export declare function handleAuthentikCallback(searchString?: string, config?: AuthentikOidcConfig): Promise<OidcSession>;
@@ -0,0 +1,282 @@
1
+ const DEFAULT_ENV_KEYS = {
2
+ issuer: "EXPO_PUBLIC_AUTHENTIK_ISSUER",
3
+ clientId: "EXPO_PUBLIC_AUTHENTIK_CLIENT_ID",
4
+ redirectUri: "EXPO_PUBLIC_AUTHENTIK_REDIRECT_URI"
5
+ };
6
+ const DEFAULT_SCOPE = "openid profile email";
7
+ const DEFAULT_STORAGE_KEY = "authentik:oidc:session";
8
+ const DEFAULT_PENDING_STORAGE_KEY = "authentik:oidc:pending";
9
+ export const OIDC_INITIAL_SEARCH = "authentik:oidc:initial-search";
10
+ function isBrowserRuntime() {
11
+ return typeof window !== "undefined";
12
+ }
13
+ function getProcessEnv() {
14
+ const maybeProcess = globalThis.process;
15
+ return maybeProcess?.env || {};
16
+ }
17
+ function resolveEnvValue(config, key) {
18
+ const envKeys = { ...DEFAULT_ENV_KEYS, ...(config.envKeys || {}) };
19
+ const explicit = key === "issuer" ? config.issuer : key === "clientId" ? config.clientId : config.redirectUri;
20
+ if (explicit && explicit.trim()) {
21
+ return explicit.trim();
22
+ }
23
+ const envSource = config.env || getProcessEnv();
24
+ const envKey = envKeys[key];
25
+ return envSource[envKey]?.trim();
26
+ }
27
+ function ensurePathSuffix(pathname) {
28
+ return pathname.endsWith("/") ? pathname : `${pathname}/`;
29
+ }
30
+ function resolveEndpoint(issuer, explicitPath, fallbackPath) {
31
+ const issuerUrl = new URL(issuer);
32
+ if (explicitPath) {
33
+ return new URL(explicitPath, `${issuerUrl.origin}/`).toString();
34
+ }
35
+ const normalizedBase = ensurePathSuffix(issuerUrl.pathname);
36
+ return new URL(`${normalizedBase}${fallbackPath}`, issuerUrl.origin).toString();
37
+ }
38
+ function getSessionStorage(config) {
39
+ if (config.sessionStorage) {
40
+ return config.sessionStorage;
41
+ }
42
+ if (!isBrowserRuntime() || !window.sessionStorage) {
43
+ throw new Error("CONFIG_ERROR: sessionStorage is unavailable in this runtime");
44
+ }
45
+ return window.sessionStorage;
46
+ }
47
+ function getLocalStorage(config) {
48
+ if (config.localStorage) {
49
+ return config.localStorage;
50
+ }
51
+ if (!isBrowserRuntime() || !window.localStorage) {
52
+ throw new Error("CONFIG_ERROR: localStorage is unavailable in this runtime");
53
+ }
54
+ return window.localStorage;
55
+ }
56
+ function getFetch(config) {
57
+ if (config.fetchFn) {
58
+ return config.fetchFn;
59
+ }
60
+ if (typeof fetch === "undefined") {
61
+ throw new Error("CONFIG_ERROR: fetch is unavailable in this runtime");
62
+ }
63
+ return fetch;
64
+ }
65
+ function resolveConfig(config = {}) {
66
+ const issuer = resolveEnvValue(config, "issuer");
67
+ const clientId = resolveEnvValue(config, "clientId");
68
+ const redirectUri = resolveEnvValue(config, "redirectUri");
69
+ if (!issuer || !clientId || !redirectUri) {
70
+ throw new Error("CONFIG_ERROR: Missing Authentik issuer, clientId, or redirectUri");
71
+ }
72
+ return {
73
+ issuer,
74
+ clientId,
75
+ redirectUri,
76
+ scope: config.scope || DEFAULT_SCOPE,
77
+ authorizePath: resolveEndpoint(issuer, config.authorizePath, "authorize/"),
78
+ tokenPath: resolveEndpoint(issuer, config.tokenPath, "token/"),
79
+ userinfoPath: resolveEndpoint(issuer, config.userinfoPath, "userinfo/"),
80
+ storageKey: config.storageKey || DEFAULT_STORAGE_KEY,
81
+ pendingStorageKey: config.pendingStorageKey || DEFAULT_PENDING_STORAGE_KEY,
82
+ providerSourceSlugs: config.providerSourceSlugs || {},
83
+ sessionStorage: getSessionStorage(config),
84
+ localStorage: getLocalStorage(config),
85
+ fetchFn: getFetch(config),
86
+ onSessionReady: config.onSessionReady
87
+ };
88
+ }
89
+ function encodeBase64Url(bytes) {
90
+ let binary = "";
91
+ for (const byte of bytes) {
92
+ binary += String.fromCharCode(byte);
93
+ }
94
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
95
+ }
96
+ function randomString(length) {
97
+ const bytes = new Uint8Array(length);
98
+ crypto.getRandomValues(bytes);
99
+ return encodeBase64Url(bytes);
100
+ }
101
+ async function sha256(input) {
102
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
103
+ return new Uint8Array(digest);
104
+ }
105
+ async function buildPkcePair() {
106
+ const verifier = randomString(64);
107
+ const challenge = encodeBase64Url(await sha256(verifier));
108
+ return { verifier, challenge };
109
+ }
110
+ function parsePendingState(rawValue) {
111
+ if (!rawValue) {
112
+ return null;
113
+ }
114
+ try {
115
+ return JSON.parse(rawValue);
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function getSourceSlug(provider, config) {
122
+ return config.providerSourceSlugs[provider] || provider;
123
+ }
124
+ export function isAuthentikConfigured(config = {}) {
125
+ try {
126
+ const issuer = resolveEnvValue(config, "issuer");
127
+ const clientId = resolveEnvValue(config, "clientId");
128
+ const redirectUri = resolveEnvValue(config, "redirectUri");
129
+ return Boolean(issuer && clientId && redirectUri);
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ export function hasPendingAuthentikCallback(searchString) {
136
+ const rawSearch = typeof searchString === "string"
137
+ ? searchString
138
+ : isBrowserRuntime()
139
+ ? window.location.search
140
+ : "";
141
+ const params = new URLSearchParams(rawSearch.startsWith("?") ? rawSearch.slice(1) : rawSearch);
142
+ return Boolean(params.get("code") && params.get("state"));
143
+ }
144
+ export function readOidcSession(config = {}) {
145
+ const storage = config.localStorage || (isBrowserRuntime() ? window.localStorage : null);
146
+ if (!storage) {
147
+ return null;
148
+ }
149
+ const key = config.storageKey || DEFAULT_STORAGE_KEY;
150
+ const rawValue = storage.getItem(key);
151
+ if (!rawValue) {
152
+ return null;
153
+ }
154
+ try {
155
+ return JSON.parse(rawValue);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ export function clearOidcSession(config = {}) {
162
+ const localStorageRef = config.localStorage || (isBrowserRuntime() ? window.localStorage : null);
163
+ const sessionStorageRef = config.sessionStorage || (isBrowserRuntime() ? window.sessionStorage : null);
164
+ const sessionKey = config.storageKey || DEFAULT_STORAGE_KEY;
165
+ const pendingKey = config.pendingStorageKey || DEFAULT_PENDING_STORAGE_KEY;
166
+ localStorageRef?.removeItem(sessionKey);
167
+ sessionStorageRef?.removeItem(pendingKey);
168
+ sessionStorageRef?.removeItem(OIDC_INITIAL_SEARCH);
169
+ }
170
+ export async function startAuthentikOAuthFlow(provider, config = {}) {
171
+ if (!isBrowserRuntime()) {
172
+ throw new Error("CONFIG_ERROR: startAuthentikOAuthFlow requires a browser runtime");
173
+ }
174
+ const resolved = resolveConfig(config);
175
+ const { verifier, challenge } = await buildPkcePair();
176
+ const state = randomString(32);
177
+ const pendingState = {
178
+ state,
179
+ provider,
180
+ codeVerifier: verifier,
181
+ createdAt: Date.now()
182
+ };
183
+ resolved.sessionStorage.setItem(resolved.pendingStorageKey, JSON.stringify(pendingState));
184
+ resolved.sessionStorage.setItem(OIDC_INITIAL_SEARCH, window.location.search || "");
185
+ const authorizeUrl = new URL(resolved.authorizePath);
186
+ authorizeUrl.searchParams.set("response_type", "code");
187
+ authorizeUrl.searchParams.set("client_id", resolved.clientId);
188
+ authorizeUrl.searchParams.set("redirect_uri", resolved.redirectUri);
189
+ authorizeUrl.searchParams.set("scope", resolved.scope);
190
+ authorizeUrl.searchParams.set("state", state);
191
+ authorizeUrl.searchParams.set("code_challenge", challenge);
192
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
193
+ const loginUrl = new URL(`/source/oauth/login/${encodeURIComponent(getSourceSlug(provider, resolved))}/`, new URL(resolved.issuer).origin);
194
+ loginUrl.searchParams.set("next", authorizeUrl.toString());
195
+ window.location.assign(loginUrl.toString());
196
+ }
197
+ export async function handleAuthentikCallback(searchString, config = {}) {
198
+ const resolved = resolveConfig(config);
199
+ const rawSearch = typeof searchString === "string"
200
+ ? searchString
201
+ : isBrowserRuntime()
202
+ ? window.location.search
203
+ : "";
204
+ const params = new URLSearchParams(rawSearch.startsWith("?") ? rawSearch.slice(1) : rawSearch);
205
+ const error = params.get("error");
206
+ if (error) {
207
+ const description = params.get("error_description") || "OAuth callback returned an error";
208
+ throw new Error(`PROVIDER_ERROR: ${error} (${description})`);
209
+ }
210
+ const code = params.get("code");
211
+ const state = params.get("state");
212
+ if (!code || !state) {
213
+ throw new Error("SESSION_ERROR: Missing code or state in Authentik callback");
214
+ }
215
+ const pending = parsePendingState(resolved.sessionStorage.getItem(resolved.pendingStorageKey));
216
+ if (!pending) {
217
+ throw new Error("SESSION_ERROR: Missing pending Authentik state in sessionStorage");
218
+ }
219
+ if (pending.state !== state) {
220
+ throw new Error("SESSION_ERROR: Invalid Authentik callback state");
221
+ }
222
+ const tokenPayload = new URLSearchParams({
223
+ grant_type: "authorization_code",
224
+ code,
225
+ redirect_uri: resolved.redirectUri,
226
+ client_id: resolved.clientId,
227
+ code_verifier: pending.codeVerifier
228
+ });
229
+ const tokenResponse = await resolved.fetchFn(resolved.tokenPath, {
230
+ method: "POST",
231
+ headers: {
232
+ "Content-Type": "application/x-www-form-urlencoded"
233
+ },
234
+ body: tokenPayload
235
+ });
236
+ if (!tokenResponse.ok) {
237
+ throw new Error(`NETWORK_ERROR: Token exchange failed with status ${tokenResponse.status}`);
238
+ }
239
+ const tokenJson = (await tokenResponse.json());
240
+ if (!tokenJson.access_token) {
241
+ throw new Error("SESSION_ERROR: Token response missing access_token");
242
+ }
243
+ const userinfoResponse = await resolved.fetchFn(resolved.userinfoPath, {
244
+ method: "GET",
245
+ headers: {
246
+ Authorization: `Bearer ${tokenJson.access_token}`
247
+ }
248
+ });
249
+ if (!userinfoResponse.ok) {
250
+ throw new Error(`NETWORK_ERROR: Userinfo request failed with status ${userinfoResponse.status}`);
251
+ }
252
+ const claims = (await userinfoResponse.json());
253
+ if (!claims.sub || !claims.iss) {
254
+ throw new Error("SESSION_ERROR: Userinfo response missing required claims (sub, iss)");
255
+ }
256
+ const now = Date.now();
257
+ const expiresAt = tokenJson.expires_in ? now + tokenJson.expires_in * 1000 : undefined;
258
+ const tokens = {
259
+ accessToken: tokenJson.access_token,
260
+ tokenType: tokenJson.token_type,
261
+ refreshToken: tokenJson.refresh_token,
262
+ idToken: tokenJson.id_token,
263
+ expiresIn: tokenJson.expires_in,
264
+ expiresAt,
265
+ scope: tokenJson.scope
266
+ };
267
+ const session = {
268
+ provider: pending.provider,
269
+ issuer: resolved.issuer,
270
+ clientId: resolved.clientId,
271
+ claims,
272
+ tokens,
273
+ createdAt: now
274
+ };
275
+ resolved.localStorage.setItem(resolved.storageKey, JSON.stringify(session));
276
+ resolved.sessionStorage.removeItem(resolved.pendingStorageKey);
277
+ if (resolved.onSessionReady) {
278
+ await resolved.onSessionReady(claims, tokens, session);
279
+ }
280
+ return session;
281
+ }
282
+ //# sourceMappingURL=AuthentikOidcClient.js.map
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export * from "./providers/FirebaseWebClient";
5
5
  export { FirebaseWebClient as FirebaseClient } from "./providers/FirebaseWebClient";
6
6
  export * from "./providers/HybridWebClient";
7
7
  export { HybridWebClient as HybridClient } from "./providers/HybridWebClient";
8
+ export * from "./AuthentikOidcClient";
package/dist/index.js CHANGED
@@ -7,4 +7,5 @@ export * from "./providers/FirebaseWebClient";
7
7
  export { FirebaseWebClient as FirebaseClient } from "./providers/FirebaseWebClient";
8
8
  export * from "./providers/HybridWebClient";
9
9
  export { HybridWebClient as HybridClient } from "./providers/HybridWebClient";
10
+ export * from "./AuthentikOidcClient";
10
11
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edcalderon/auth",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "A universal, provider-agnostic authentication package (Web + Next.js + Expo/React Native)",
5
5
  "exports": {
6
6
  ".": {