@edcalderon/auth 1.2.1 → 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 +19 -0
- package/README.md +74 -4
- package/dist/AuthentikOidcClient.d.ts +58 -0
- package/dist/AuthentikOidcClient.js +282 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/supabase/README.md +65 -0
- package/supabase/migrations/001_create_app_users.sql +147 -0
- package/supabase/migrations/002_sync_auth_users_to_app_users.sql +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
11
|
+
## [1.2.2] - 2026-03-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `packages/auth/supabase/` SQL templates for a vendor-independent `public.users` table and optional Supabase Auth sync trigger.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Hardened the OIDC upsert migration so identity writes require a trusted server-side caller instead of the `anon` role.
|
|
20
|
+
- Preserved existing user profile fields when optional claims are omitted during upserts or Supabase sync updates.
|
|
21
|
+
|
|
3
22
|
## [1.2.1] - 2026-03-02
|
|
4
23
|
|
|
5
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -11,12 +11,13 @@ Swap between Supabase, Firebase, Hybrid, or any custom provider without changing
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
## 📋 Latest Changes (v1.
|
|
14
|
+
## 📋 Latest Changes (v1.3.0)
|
|
15
15
|
|
|
16
|
-
###
|
|
16
|
+
### Added
|
|
17
17
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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.
|
|
20
21
|
|
|
21
22
|
For full version history, see [CHANGELOG.md](./CHANGELOG.md) and [GitHub releases](https://github.com/edcalderon/my-second-brain/releases)
|
|
22
23
|
|
|
@@ -68,6 +69,75 @@ pnpm add firebase
|
|
|
68
69
|
pnpm add react-native
|
|
69
70
|
```
|
|
70
71
|
|
|
72
|
+
### Supabase SQL Templates
|
|
73
|
+
|
|
74
|
+
If you want an application-owned user table instead of coupling your identity model to `auth.users`, copy the reference SQL templates in `packages/auth/supabase/` into your Supabase project and apply them with `supabase db push`.
|
|
75
|
+
|
|
76
|
+
- `001_create_app_users.sql`: vendor-independent `public.users` table plus secure server-side OIDC upsert RPC
|
|
77
|
+
- `002_sync_auth_users_to_app_users.sql`: optional trigger and backfill for projects using Supabase Auth
|
|
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
|
+
|
|
71
141
|
---
|
|
72
142
|
|
|
73
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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @edcalderon/auth Supabase SQL Templates
|
|
2
|
+
|
|
3
|
+
Copy these files into your own Supabase project. They are reference migrations for projects using @edcalderon/auth, not runtime imports.
|
|
4
|
+
|
|
5
|
+
## Quick Setups
|
|
6
|
+
|
|
7
|
+
| Use case | Apply | Result |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| Supabase Auth quick setup | `001` + `002` | Vendor-independent `public.users` plus automatic `auth.users -> public.users` sync |
|
|
10
|
+
| External OIDC only | `001` | Vendor-independent `public.users` plus a secure server-side upsert RPC |
|
|
11
|
+
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
| File | Purpose |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `migrations/001_create_app_users.sql` | Creates `public.users`, timestamps trigger, and `upsert_oidc_user()` |
|
|
17
|
+
| `migrations/002_sync_auth_users_to_app_users.sql` | Optional trigger/backfill for projects that use Supabase Auth |
|
|
18
|
+
|
|
19
|
+
## What You Get
|
|
20
|
+
|
|
21
|
+
- A vendor-independent `public.users` table keyed by `(sub, iss)`.
|
|
22
|
+
- A clean migration path away from Supabase Auth later.
|
|
23
|
+
- An optional trigger that mirrors Supabase Auth users automatically.
|
|
24
|
+
- Safe default behavior for optional profile fields so missing claims do not erase stored data.
|
|
25
|
+
|
|
26
|
+
## Security Model
|
|
27
|
+
|
|
28
|
+
`upsert_oidc_user()` is intentionally not callable by the `anon` role.
|
|
29
|
+
|
|
30
|
+
- Supabase Auth users are synced automatically by migration `002`.
|
|
31
|
+
- External OIDC users should be written by a trusted server or Edge Function after token verification.
|
|
32
|
+
- Optional fields such as `email`, `name`, `picture`, and `provider` only overwrite stored values when a non-null value is provided.
|
|
33
|
+
|
|
34
|
+
## Apply
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Copy into your project
|
|
38
|
+
cp packages/auth/supabase/migrations/*.sql your-project/supabase/migrations/
|
|
39
|
+
|
|
40
|
+
# Apply them with the Supabase CLI
|
|
41
|
+
supabase db push
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If you only want the vendor-independent table and are not using Supabase Auth as a provider, apply only `001_create_app_users.sql`.
|
|
45
|
+
|
|
46
|
+
## Suggested External OIDC Flow
|
|
47
|
+
|
|
48
|
+
1. Verify the provider token in your server or Edge Function.
|
|
49
|
+
2. Extract `sub`, `iss`, and optional claims.
|
|
50
|
+
3. Call `upsert_oidc_user()` with the service-role key.
|
|
51
|
+
4. Use the returned `public.users.id` as your application user identifier.
|
|
52
|
+
|
|
53
|
+
## Why `(sub, iss)`
|
|
54
|
+
|
|
55
|
+
The `(sub, iss)` pair follows the OIDC identity model and allows multiple providers to coexist safely:
|
|
56
|
+
|
|
57
|
+
| Provider | `sub` | `iss` |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| Supabase Auth | `auth.users.id` | `supabase` |
|
|
60
|
+
| Authentik | provider UUID | issuer URL |
|
|
61
|
+
| Auth0 | provider user id | issuer URL |
|
|
62
|
+
| Firebase | provider uid | issuer URL |
|
|
63
|
+
| Custom OIDC | JWT `sub` | JWT `iss` |
|
|
64
|
+
|
|
65
|
+
That gives you a stable application-owned identity layer even if you later replace the auth provider.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 001_create_app_users.sql
|
|
3
|
+
-- Vendor-independent user registry for projects using @edcalderon/auth.
|
|
4
|
+
--
|
|
5
|
+
-- This creates an application-owned public.users table keyed by (sub, iss)
|
|
6
|
+
-- and a secure upsert RPC intended for trusted server-side use.
|
|
7
|
+
-- ============================================================================
|
|
8
|
+
|
|
9
|
+
create extension if not exists pgcrypto;
|
|
10
|
+
|
|
11
|
+
create table if not exists public.users (
|
|
12
|
+
id uuid primary key default gen_random_uuid(),
|
|
13
|
+
sub text not null,
|
|
14
|
+
iss text not null,
|
|
15
|
+
email text,
|
|
16
|
+
email_verified boolean not null default false,
|
|
17
|
+
name text,
|
|
18
|
+
picture text,
|
|
19
|
+
provider text,
|
|
20
|
+
raw_claims jsonb not null default '{}'::jsonb,
|
|
21
|
+
created_at timestamptz not null default timezone('utc', now()),
|
|
22
|
+
updated_at timestamptz not null default timezone('utc', now()),
|
|
23
|
+
|
|
24
|
+
constraint users_sub_iss_uq unique (sub, iss),
|
|
25
|
+
constraint users_sub_len_chk check (char_length(sub) <= 256),
|
|
26
|
+
constraint users_iss_len_chk check (char_length(iss) <= 512),
|
|
27
|
+
constraint users_email_len_chk check (
|
|
28
|
+
email is null or char_length(email) <= 320
|
|
29
|
+
)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
create index if not exists users_email_idx
|
|
33
|
+
on public.users (lower(email))
|
|
34
|
+
where email is not null;
|
|
35
|
+
|
|
36
|
+
create index if not exists users_provider_idx
|
|
37
|
+
on public.users (provider)
|
|
38
|
+
where provider is not null;
|
|
39
|
+
|
|
40
|
+
create or replace function public.users_set_updated_at()
|
|
41
|
+
returns trigger
|
|
42
|
+
language plpgsql
|
|
43
|
+
as $$
|
|
44
|
+
begin
|
|
45
|
+
new.updated_at = timezone('utc', now());
|
|
46
|
+
return new;
|
|
47
|
+
end;
|
|
48
|
+
$$;
|
|
49
|
+
|
|
50
|
+
drop trigger if exists trg_users_set_updated_at on public.users;
|
|
51
|
+
|
|
52
|
+
create trigger trg_users_set_updated_at
|
|
53
|
+
before update on public.users
|
|
54
|
+
for each row
|
|
55
|
+
execute function public.users_set_updated_at();
|
|
56
|
+
|
|
57
|
+
create or replace function public.upsert_oidc_user(
|
|
58
|
+
p_sub text,
|
|
59
|
+
p_iss text,
|
|
60
|
+
p_email text default null,
|
|
61
|
+
p_email_verified boolean default false,
|
|
62
|
+
p_name text default null,
|
|
63
|
+
p_picture text default null,
|
|
64
|
+
p_provider text default null,
|
|
65
|
+
p_raw_claims jsonb default '{}'::jsonb
|
|
66
|
+
)
|
|
67
|
+
returns public.users
|
|
68
|
+
language plpgsql
|
|
69
|
+
security definer
|
|
70
|
+
set search_path = public
|
|
71
|
+
as $$
|
|
72
|
+
declare
|
|
73
|
+
v_claims jsonb := coalesce(
|
|
74
|
+
nullif(current_setting('request.jwt.claims', true), ''),
|
|
75
|
+
'{}'
|
|
76
|
+
)::jsonb;
|
|
77
|
+
v_role text := coalesce(nullif(v_claims ->> 'role', ''), 'unknown');
|
|
78
|
+
v_user public.users;
|
|
79
|
+
begin
|
|
80
|
+
if v_role <> 'service_role' then
|
|
81
|
+
raise exception 'upsert_oidc_user() requires a trusted server-side caller';
|
|
82
|
+
end if;
|
|
83
|
+
|
|
84
|
+
if nullif(trim(p_sub), '') is null or nullif(trim(p_iss), '') is null then
|
|
85
|
+
raise exception 'upsert_oidc_user() requires non-empty p_sub and p_iss';
|
|
86
|
+
end if;
|
|
87
|
+
|
|
88
|
+
insert into public.users (
|
|
89
|
+
sub,
|
|
90
|
+
iss,
|
|
91
|
+
email,
|
|
92
|
+
email_verified,
|
|
93
|
+
name,
|
|
94
|
+
picture,
|
|
95
|
+
provider,
|
|
96
|
+
raw_claims
|
|
97
|
+
)
|
|
98
|
+
values (
|
|
99
|
+
trim(p_sub),
|
|
100
|
+
trim(p_iss),
|
|
101
|
+
nullif(trim(p_email), ''),
|
|
102
|
+
p_email_verified,
|
|
103
|
+
nullif(trim(p_name), ''),
|
|
104
|
+
nullif(trim(p_picture), ''),
|
|
105
|
+
nullif(trim(p_provider), ''),
|
|
106
|
+
coalesce(p_raw_claims, '{}'::jsonb)
|
|
107
|
+
)
|
|
108
|
+
on conflict (sub, iss) do update
|
|
109
|
+
set email = coalesce(excluded.email, public.users.email),
|
|
110
|
+
email_verified = public.users.email_verified or excluded.email_verified,
|
|
111
|
+
name = coalesce(excluded.name, public.users.name),
|
|
112
|
+
picture = coalesce(excluded.picture, public.users.picture),
|
|
113
|
+
provider = coalesce(excluded.provider, public.users.provider),
|
|
114
|
+
raw_claims = case
|
|
115
|
+
when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
|
|
116
|
+
else public.users.raw_claims || excluded.raw_claims
|
|
117
|
+
end,
|
|
118
|
+
updated_at = timezone('utc', now())
|
|
119
|
+
returning * into v_user;
|
|
120
|
+
|
|
121
|
+
return v_user;
|
|
122
|
+
end;
|
|
123
|
+
$$;
|
|
124
|
+
|
|
125
|
+
revoke all on function public.upsert_oidc_user(
|
|
126
|
+
text,
|
|
127
|
+
text,
|
|
128
|
+
text,
|
|
129
|
+
boolean,
|
|
130
|
+
text,
|
|
131
|
+
text,
|
|
132
|
+
text,
|
|
133
|
+
jsonb
|
|
134
|
+
) from public, anon, authenticated;
|
|
135
|
+
|
|
136
|
+
grant execute on function public.upsert_oidc_user(
|
|
137
|
+
text,
|
|
138
|
+
text,
|
|
139
|
+
text,
|
|
140
|
+
boolean,
|
|
141
|
+
text,
|
|
142
|
+
text,
|
|
143
|
+
text,
|
|
144
|
+
jsonb
|
|
145
|
+
) to service_role;
|
|
146
|
+
|
|
147
|
+
alter table public.users enable row level security;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 002_sync_auth_users_to_app_users.sql
|
|
3
|
+
-- Optional trigger for projects that use Supabase Auth.
|
|
4
|
+
-- Keeps auth.users mirrored into public.users and backfills existing users.
|
|
5
|
+
-- ============================================================================
|
|
6
|
+
|
|
7
|
+
create or replace function public.sync_auth_user_to_app_users()
|
|
8
|
+
returns trigger
|
|
9
|
+
language plpgsql
|
|
10
|
+
security definer
|
|
11
|
+
set search_path = public
|
|
12
|
+
as $$
|
|
13
|
+
begin
|
|
14
|
+
if new.is_anonymous then
|
|
15
|
+
return new;
|
|
16
|
+
end if;
|
|
17
|
+
|
|
18
|
+
insert into public.users (
|
|
19
|
+
sub,
|
|
20
|
+
iss,
|
|
21
|
+
email,
|
|
22
|
+
email_verified,
|
|
23
|
+
name,
|
|
24
|
+
picture,
|
|
25
|
+
provider,
|
|
26
|
+
raw_claims
|
|
27
|
+
)
|
|
28
|
+
values (
|
|
29
|
+
new.id::text,
|
|
30
|
+
'supabase',
|
|
31
|
+
new.email,
|
|
32
|
+
new.email_confirmed_at is not null,
|
|
33
|
+
coalesce(
|
|
34
|
+
new.raw_user_meta_data ->> 'name',
|
|
35
|
+
new.raw_user_meta_data ->> 'full_name',
|
|
36
|
+
split_part(coalesce(new.email, ''), '@', 1)
|
|
37
|
+
),
|
|
38
|
+
new.raw_user_meta_data ->> 'avatar_url',
|
|
39
|
+
coalesce(new.raw_app_meta_data ->> 'provider', 'email'),
|
|
40
|
+
coalesce(new.raw_user_meta_data, '{}'::jsonb)
|
|
41
|
+
)
|
|
42
|
+
on conflict (sub, iss) do update
|
|
43
|
+
set email = coalesce(excluded.email, public.users.email),
|
|
44
|
+
email_verified = public.users.email_verified or excluded.email_verified,
|
|
45
|
+
name = coalesce(excluded.name, public.users.name),
|
|
46
|
+
picture = coalesce(excluded.picture, public.users.picture),
|
|
47
|
+
provider = coalesce(excluded.provider, public.users.provider),
|
|
48
|
+
raw_claims = case
|
|
49
|
+
when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
|
|
50
|
+
else public.users.raw_claims || excluded.raw_claims
|
|
51
|
+
end,
|
|
52
|
+
updated_at = timezone('utc', now());
|
|
53
|
+
|
|
54
|
+
return new;
|
|
55
|
+
end;
|
|
56
|
+
$$;
|
|
57
|
+
|
|
58
|
+
drop trigger if exists trg_auth_users_sync_to_app_users on auth.users;
|
|
59
|
+
|
|
60
|
+
create trigger trg_auth_users_sync_to_app_users
|
|
61
|
+
after insert or update of email_confirmed_at, email, raw_user_meta_data, raw_app_meta_data
|
|
62
|
+
on auth.users
|
|
63
|
+
for each row
|
|
64
|
+
execute function public.sync_auth_user_to_app_users();
|
|
65
|
+
|
|
66
|
+
insert into public.users (
|
|
67
|
+
sub,
|
|
68
|
+
iss,
|
|
69
|
+
email,
|
|
70
|
+
email_verified,
|
|
71
|
+
name,
|
|
72
|
+
picture,
|
|
73
|
+
provider,
|
|
74
|
+
raw_claims
|
|
75
|
+
)
|
|
76
|
+
select
|
|
77
|
+
u.id::text,
|
|
78
|
+
'supabase',
|
|
79
|
+
u.email,
|
|
80
|
+
u.email_confirmed_at is not null,
|
|
81
|
+
coalesce(
|
|
82
|
+
u.raw_user_meta_data ->> 'name',
|
|
83
|
+
u.raw_user_meta_data ->> 'full_name',
|
|
84
|
+
split_part(coalesce(u.email, ''), '@', 1)
|
|
85
|
+
),
|
|
86
|
+
u.raw_user_meta_data ->> 'avatar_url',
|
|
87
|
+
coalesce(u.raw_app_meta_data ->> 'provider', 'email'),
|
|
88
|
+
coalesce(u.raw_user_meta_data, '{}'::jsonb)
|
|
89
|
+
from auth.users u
|
|
90
|
+
where not u.is_anonymous
|
|
91
|
+
on conflict (sub, iss) do update
|
|
92
|
+
set email = coalesce(excluded.email, public.users.email),
|
|
93
|
+
email_verified = public.users.email_verified or excluded.email_verified,
|
|
94
|
+
name = coalesce(excluded.name, public.users.name),
|
|
95
|
+
picture = coalesce(excluded.picture, public.users.picture),
|
|
96
|
+
provider = coalesce(excluded.provider, public.users.provider),
|
|
97
|
+
raw_claims = case
|
|
98
|
+
when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
|
|
99
|
+
else public.users.raw_claims || excluded.raw_claims
|
|
100
|
+
end,
|
|
101
|
+
updated_at = timezone('utc', now());
|