@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-03-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- ✨ **Authentik flow + provisioning kit** (`@edcalderon/auth/authentik`) — a reusable set of helpers generalised from the production CIG Authentik implementation.
|
|
8
|
+
- 🔀 **Cross-origin PKCE relay** — `createRelayPageHtml()`, `parseRelayParams()`, `readRelayStorage()`, `clearRelayStorage()` for apps where login UI and callback handler live on different origins.
|
|
9
|
+
- 🔄 **Enhanced callback handler** — `exchangeCode()`, `fetchClaims()`, `processCallback()` with blocking provisioning gate that prevents redirect until user sync completes.
|
|
10
|
+
- 🚪 **Logout orchestrator** — `revokeToken()`, `buildEndSessionUrl()`, `orchestrateLogout()` implementing the full RP-initiated logout flow.
|
|
11
|
+
- 🔌 **Provisioning adapter layer** — pluggable adapters: `NoopProvisioningAdapter`, `createProvisioningAdapter()`, `SupabaseSyncAdapter` with identity-first matching and rollback on failure.
|
|
12
|
+
- 🏥 **Config validation / doctor** — `validateAuthentikConfig()`, `validateSupabaseSyncConfig()`, `validateFullConfig()` for startup / deploy-time validation (detects `supabase_not_configured`).
|
|
13
|
+
- 🛡️ **Safe redirect resolver** — `resolveSafeRedirect()` with origin allowlist to prevent open-redirect vulnerabilities.
|
|
14
|
+
- 📦 **New subpath export** — `@edcalderon/auth/authentik` barrel export for all Authentik-specific modules.
|
|
15
|
+
- 🗄️ **SQL migration 003** — `003_authentik_shadow_auth_users.sql` adds shadow auth user linkage columns and `link_shadow_auth_user()` RPC.
|
|
16
|
+
- 🧪 **96 tests** across 6 test suites covering relay, callback, logout, provisioning (incl. paginated page-2 lookups, shadow linkage RPC, rollback), config validation (incl. endpoint discovery), and redirect safety.
|
|
17
|
+
|
|
18
|
+
## [1.3.0] - 2026-03-19
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added canonical `AuthentikOidcClient` browser helpers with PKCE-only OAuth flow utilities (`isAuthentikConfigured`, `startAuthentikOAuthFlow`, `handleAuthentikCallback`, `readOidcSession`, `clearOidcSession`, `hasPendingAuthentikCallback`, `OIDC_INITIAL_SEARCH`).
|
|
23
|
+
- Added exported Authentik OIDC types: `OidcClaims`, `OidcSession`, `OidcProvider`.
|
|
24
|
+
- Added README guidance for Authentik setup and the known Authentik `2026.2.1` social re-link bug workaround.
|
|
25
|
+
|
|
3
26
|
## [1.2.2] - 2026-03-19
|
|
4
27
|
|
|
5
28
|
### Added
|
package/README.md
CHANGED
|
@@ -11,16 +11,20 @@ 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.4.0)
|
|
15
15
|
|
|
16
16
|
### Added
|
|
17
17
|
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
18
|
+
- ✨ **Authentik flow + provisioning kit** (`@edcalderon/auth/authentik`) — a reusable set of helpers generalised from the production CIG Authentik implementation.
|
|
19
|
+
- 🔀 **Cross-origin PKCE relay** — `createRelayPageHtml()`, `parseRelayParams()`, `readRelayStorage()`, `clearRelayStorage()` for apps where login UI and callback handler live on different origins.
|
|
20
|
+
- 🔄 **Enhanced callback handler** — `exchangeCode()`, `fetchClaims()`, `processCallback()` with blocking provisioning gate that prevents redirect until user sync completes.
|
|
21
|
+
- 🚪 **Logout orchestrator** — `revokeToken()`, `buildEndSessionUrl()`, `orchestrateLogout()` implementing the full RP-initiated logout flow.
|
|
22
|
+
- 🔌 **Provisioning adapter layer** — pluggable adapters: `NoopProvisioningAdapter`, `createProvisioningAdapter()`, `SupabaseSyncAdapter` with identity-first matching and rollback on failure.
|
|
23
|
+
- 🏥 **Config validation / doctor** — `validateAuthentikConfig()`, `validateSupabaseSyncConfig()`, `validateFullConfig()` for startup / deploy-time validation (detects `supabase_not_configured`).
|
|
24
|
+
- 🛡️ **Safe redirect resolver** — `resolveSafeRedirect()` with origin allowlist to prevent open-redirect vulnerabilities.
|
|
25
|
+
- 📦 **New subpath export** — `@edcalderon/auth/authentik` barrel export for all Authentik-specific modules.
|
|
26
|
+
- 🗄️ **SQL migration 003** — `003_authentik_shadow_auth_users.sql` adds shadow auth user linkage columns and `link_shadow_auth_user()` RPC.
|
|
27
|
+
- 🧪 **96 tests** across 6 test suites covering relay, callback, logout, provisioning (incl. paginated page-2 lookups, shadow linkage RPC, rollback), config validation (incl. endpoint discovery), and redirect safety.
|
|
24
28
|
|
|
25
29
|
For full version history, see [CHANGELOG.md](./CHANGELOG.md) and [GitHub releases](https://github.com/edcalderon/my-second-brain/releases)
|
|
26
30
|
|
|
@@ -79,6 +83,68 @@ If you want an application-owned user table instead of coupling your identity mo
|
|
|
79
83
|
- `001_create_app_users.sql`: vendor-independent `public.users` table plus secure server-side OIDC upsert RPC
|
|
80
84
|
- `002_sync_auth_users_to_app_users.sql`: optional trigger and backfill for projects using Supabase Auth
|
|
81
85
|
|
|
86
|
+
### Authentik OIDC Client (Canonical)
|
|
87
|
+
|
|
88
|
+
`@edcalderon/auth` exports a browser-first Authentik OIDC helper that is decoupled from Supabase and can be used with any backend session strategy.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import {
|
|
92
|
+
isAuthentikConfigured,
|
|
93
|
+
startAuthentikOAuthFlow,
|
|
94
|
+
handleAuthentikCallback,
|
|
95
|
+
readOidcSession,
|
|
96
|
+
clearOidcSession,
|
|
97
|
+
hasPendingAuthentikCallback,
|
|
98
|
+
} from "@edcalderon/auth";
|
|
99
|
+
|
|
100
|
+
if (isAuthentikConfigured()) {
|
|
101
|
+
await startAuthentikOAuthFlow("google", {
|
|
102
|
+
providerSourceSlugs: {
|
|
103
|
+
google: "google",
|
|
104
|
+
discord: "discord",
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (hasPendingAuthentikCallback(window.location.search)) {
|
|
110
|
+
const session = await handleAuthentikCallback(window.location.search, {
|
|
111
|
+
onSessionReady: async (claims, tokens) => {
|
|
112
|
+
// Optional hook for API upsert/session handoff.
|
|
113
|
+
console.log(claims.sub, tokens.accessToken);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
console.log("OIDC session", session);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const existing = readOidcSession();
|
|
121
|
+
if (!existing) {
|
|
122
|
+
clearOidcSession();
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Required env vars (defaults):
|
|
127
|
+
|
|
128
|
+
| Var | Description |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `EXPO_PUBLIC_AUTHENTIK_ISSUER` | `https://<host>/application/o/<app-slug>/` |
|
|
131
|
+
| `EXPO_PUBLIC_AUTHENTIK_CLIENT_ID` | OAuth2 provider client ID |
|
|
132
|
+
| `EXPO_PUBLIC_AUTHENTIK_REDIRECT_URI` | App redirect URI registered in Authentik |
|
|
133
|
+
|
|
134
|
+
You can override env key names with `envKeys` and pass direct values with `issuer`, `clientId`, and `redirectUri`.
|
|
135
|
+
|
|
136
|
+
Authentik setup checklist:
|
|
137
|
+
|
|
138
|
+
1. Configure an OAuth2/OIDC provider in Authentik with PKCE enabled.
|
|
139
|
+
2. Ensure redirect URIs match your app origin/path exactly.
|
|
140
|
+
3. Configure source login slugs (`providerSourceSlugs`) for each social provider.
|
|
141
|
+
4. Use `onSessionReady` to hand off claims/tokens to your backend session flow.
|
|
142
|
+
|
|
143
|
+
Known Authentik `2026.2.1` bug workaround:
|
|
144
|
+
|
|
145
|
+
- 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.
|
|
146
|
+
- Track the upstream Authentik issue and re-apply the patch after container upgrades until a fixed release is available.
|
|
147
|
+
|
|
82
148
|
---
|
|
83
149
|
|
|
84
150
|
## 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,284 @@
|
|
|
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
|
+
// Authentik OAuth endpoints (authorize, token, userinfo) live at the parent
|
|
36
|
+
// of the issuer path: issuer = /application/o/<slug>/, endpoint = /application/o/<ep>/.
|
|
37
|
+
const base = ensurePathSuffix(issuer);
|
|
38
|
+
return new URL(`../${fallbackPath}`, base).toString();
|
|
39
|
+
}
|
|
40
|
+
function getSessionStorage(config) {
|
|
41
|
+
if (config.sessionStorage) {
|
|
42
|
+
return config.sessionStorage;
|
|
43
|
+
}
|
|
44
|
+
if (!isBrowserRuntime() || !window.sessionStorage) {
|
|
45
|
+
throw new Error("CONFIG_ERROR: sessionStorage is unavailable in this runtime");
|
|
46
|
+
}
|
|
47
|
+
return window.sessionStorage;
|
|
48
|
+
}
|
|
49
|
+
function getLocalStorage(config) {
|
|
50
|
+
if (config.localStorage) {
|
|
51
|
+
return config.localStorage;
|
|
52
|
+
}
|
|
53
|
+
if (!isBrowserRuntime() || !window.localStorage) {
|
|
54
|
+
throw new Error("CONFIG_ERROR: localStorage is unavailable in this runtime");
|
|
55
|
+
}
|
|
56
|
+
return window.localStorage;
|
|
57
|
+
}
|
|
58
|
+
function getFetch(config) {
|
|
59
|
+
if (config.fetchFn) {
|
|
60
|
+
return config.fetchFn;
|
|
61
|
+
}
|
|
62
|
+
if (typeof fetch === "undefined") {
|
|
63
|
+
throw new Error("CONFIG_ERROR: fetch is unavailable in this runtime");
|
|
64
|
+
}
|
|
65
|
+
return fetch;
|
|
66
|
+
}
|
|
67
|
+
function resolveConfig(config = {}) {
|
|
68
|
+
const issuer = resolveEnvValue(config, "issuer");
|
|
69
|
+
const clientId = resolveEnvValue(config, "clientId");
|
|
70
|
+
const redirectUri = resolveEnvValue(config, "redirectUri");
|
|
71
|
+
if (!issuer || !clientId || !redirectUri) {
|
|
72
|
+
throw new Error("CONFIG_ERROR: Missing Authentik issuer, clientId, or redirectUri");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
issuer,
|
|
76
|
+
clientId,
|
|
77
|
+
redirectUri,
|
|
78
|
+
scope: config.scope || DEFAULT_SCOPE,
|
|
79
|
+
authorizePath: resolveEndpoint(issuer, config.authorizePath, "authorize/"),
|
|
80
|
+
tokenPath: resolveEndpoint(issuer, config.tokenPath, "token/"),
|
|
81
|
+
userinfoPath: resolveEndpoint(issuer, config.userinfoPath, "userinfo/"),
|
|
82
|
+
storageKey: config.storageKey || DEFAULT_STORAGE_KEY,
|
|
83
|
+
pendingStorageKey: config.pendingStorageKey || DEFAULT_PENDING_STORAGE_KEY,
|
|
84
|
+
providerSourceSlugs: config.providerSourceSlugs || {},
|
|
85
|
+
sessionStorage: getSessionStorage(config),
|
|
86
|
+
localStorage: getLocalStorage(config),
|
|
87
|
+
fetchFn: getFetch(config),
|
|
88
|
+
onSessionReady: config.onSessionReady
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function encodeBase64Url(bytes) {
|
|
92
|
+
let binary = "";
|
|
93
|
+
for (const byte of bytes) {
|
|
94
|
+
binary += String.fromCharCode(byte);
|
|
95
|
+
}
|
|
96
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
97
|
+
}
|
|
98
|
+
function randomString(length) {
|
|
99
|
+
const bytes = new Uint8Array(length);
|
|
100
|
+
crypto.getRandomValues(bytes);
|
|
101
|
+
return encodeBase64Url(bytes);
|
|
102
|
+
}
|
|
103
|
+
async function sha256(input) {
|
|
104
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
105
|
+
return new Uint8Array(digest);
|
|
106
|
+
}
|
|
107
|
+
async function buildPkcePair() {
|
|
108
|
+
const verifier = randomString(64);
|
|
109
|
+
const challenge = encodeBase64Url(await sha256(verifier));
|
|
110
|
+
return { verifier, challenge };
|
|
111
|
+
}
|
|
112
|
+
function parsePendingState(rawValue) {
|
|
113
|
+
if (!rawValue) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(rawValue);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function getSourceSlug(provider, config) {
|
|
124
|
+
return config.providerSourceSlugs[provider] || provider;
|
|
125
|
+
}
|
|
126
|
+
export function isAuthentikConfigured(config = {}) {
|
|
127
|
+
try {
|
|
128
|
+
const issuer = resolveEnvValue(config, "issuer");
|
|
129
|
+
const clientId = resolveEnvValue(config, "clientId");
|
|
130
|
+
const redirectUri = resolveEnvValue(config, "redirectUri");
|
|
131
|
+
return Boolean(issuer && clientId && redirectUri);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function hasPendingAuthentikCallback(searchString) {
|
|
138
|
+
const rawSearch = typeof searchString === "string"
|
|
139
|
+
? searchString
|
|
140
|
+
: isBrowserRuntime()
|
|
141
|
+
? window.location.search
|
|
142
|
+
: "";
|
|
143
|
+
const params = new URLSearchParams(rawSearch.startsWith("?") ? rawSearch.slice(1) : rawSearch);
|
|
144
|
+
return Boolean(params.get("code") && params.get("state"));
|
|
145
|
+
}
|
|
146
|
+
export function readOidcSession(config = {}) {
|
|
147
|
+
const storage = config.localStorage || (isBrowserRuntime() ? window.localStorage : null);
|
|
148
|
+
if (!storage) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const key = config.storageKey || DEFAULT_STORAGE_KEY;
|
|
152
|
+
const rawValue = storage.getItem(key);
|
|
153
|
+
if (!rawValue) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(rawValue);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function clearOidcSession(config = {}) {
|
|
164
|
+
const localStorageRef = config.localStorage || (isBrowserRuntime() ? window.localStorage : null);
|
|
165
|
+
const sessionStorageRef = config.sessionStorage || (isBrowserRuntime() ? window.sessionStorage : null);
|
|
166
|
+
const sessionKey = config.storageKey || DEFAULT_STORAGE_KEY;
|
|
167
|
+
const pendingKey = config.pendingStorageKey || DEFAULT_PENDING_STORAGE_KEY;
|
|
168
|
+
localStorageRef?.removeItem(sessionKey);
|
|
169
|
+
sessionStorageRef?.removeItem(pendingKey);
|
|
170
|
+
sessionStorageRef?.removeItem(OIDC_INITIAL_SEARCH);
|
|
171
|
+
}
|
|
172
|
+
export async function startAuthentikOAuthFlow(provider, config = {}) {
|
|
173
|
+
if (!isBrowserRuntime()) {
|
|
174
|
+
throw new Error("CONFIG_ERROR: startAuthentikOAuthFlow requires a browser runtime");
|
|
175
|
+
}
|
|
176
|
+
const resolved = resolveConfig(config);
|
|
177
|
+
const { verifier, challenge } = await buildPkcePair();
|
|
178
|
+
const state = randomString(32);
|
|
179
|
+
const pendingState = {
|
|
180
|
+
state,
|
|
181
|
+
provider,
|
|
182
|
+
codeVerifier: verifier,
|
|
183
|
+
createdAt: Date.now()
|
|
184
|
+
};
|
|
185
|
+
resolved.sessionStorage.setItem(resolved.pendingStorageKey, JSON.stringify(pendingState));
|
|
186
|
+
resolved.sessionStorage.setItem(OIDC_INITIAL_SEARCH, window.location.search || "");
|
|
187
|
+
const authorizeUrl = new URL(resolved.authorizePath);
|
|
188
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
189
|
+
authorizeUrl.searchParams.set("client_id", resolved.clientId);
|
|
190
|
+
authorizeUrl.searchParams.set("redirect_uri", resolved.redirectUri);
|
|
191
|
+
authorizeUrl.searchParams.set("scope", resolved.scope);
|
|
192
|
+
authorizeUrl.searchParams.set("state", state);
|
|
193
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
194
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
195
|
+
const loginUrl = new URL(`/source/oauth/login/${encodeURIComponent(getSourceSlug(provider, resolved))}/`, new URL(resolved.issuer).origin);
|
|
196
|
+
loginUrl.searchParams.set("next", authorizeUrl.toString());
|
|
197
|
+
window.location.assign(loginUrl.toString());
|
|
198
|
+
}
|
|
199
|
+
export async function handleAuthentikCallback(searchString, config = {}) {
|
|
200
|
+
const resolved = resolveConfig(config);
|
|
201
|
+
const rawSearch = typeof searchString === "string"
|
|
202
|
+
? searchString
|
|
203
|
+
: isBrowserRuntime()
|
|
204
|
+
? window.location.search
|
|
205
|
+
: "";
|
|
206
|
+
const params = new URLSearchParams(rawSearch.startsWith("?") ? rawSearch.slice(1) : rawSearch);
|
|
207
|
+
const error = params.get("error");
|
|
208
|
+
if (error) {
|
|
209
|
+
const description = params.get("error_description") || "OAuth callback returned an error";
|
|
210
|
+
throw new Error(`PROVIDER_ERROR: ${error} (${description})`);
|
|
211
|
+
}
|
|
212
|
+
const code = params.get("code");
|
|
213
|
+
const state = params.get("state");
|
|
214
|
+
if (!code || !state) {
|
|
215
|
+
throw new Error("SESSION_ERROR: Missing code or state in Authentik callback");
|
|
216
|
+
}
|
|
217
|
+
const pending = parsePendingState(resolved.sessionStorage.getItem(resolved.pendingStorageKey));
|
|
218
|
+
if (!pending) {
|
|
219
|
+
throw new Error("SESSION_ERROR: Missing pending Authentik state in sessionStorage");
|
|
220
|
+
}
|
|
221
|
+
if (pending.state !== state) {
|
|
222
|
+
throw new Error("SESSION_ERROR: Invalid Authentik callback state");
|
|
223
|
+
}
|
|
224
|
+
const tokenPayload = new URLSearchParams({
|
|
225
|
+
grant_type: "authorization_code",
|
|
226
|
+
code,
|
|
227
|
+
redirect_uri: resolved.redirectUri,
|
|
228
|
+
client_id: resolved.clientId,
|
|
229
|
+
code_verifier: pending.codeVerifier
|
|
230
|
+
});
|
|
231
|
+
const tokenResponse = await resolved.fetchFn(resolved.tokenPath, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: {
|
|
234
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
235
|
+
},
|
|
236
|
+
body: tokenPayload
|
|
237
|
+
});
|
|
238
|
+
if (!tokenResponse.ok) {
|
|
239
|
+
throw new Error(`NETWORK_ERROR: Token exchange failed with status ${tokenResponse.status}`);
|
|
240
|
+
}
|
|
241
|
+
const tokenJson = (await tokenResponse.json());
|
|
242
|
+
if (!tokenJson.access_token) {
|
|
243
|
+
throw new Error("SESSION_ERROR: Token response missing access_token");
|
|
244
|
+
}
|
|
245
|
+
const userinfoResponse = await resolved.fetchFn(resolved.userinfoPath, {
|
|
246
|
+
method: "GET",
|
|
247
|
+
headers: {
|
|
248
|
+
Authorization: `Bearer ${tokenJson.access_token}`
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
if (!userinfoResponse.ok) {
|
|
252
|
+
throw new Error(`NETWORK_ERROR: Userinfo request failed with status ${userinfoResponse.status}`);
|
|
253
|
+
}
|
|
254
|
+
const claims = (await userinfoResponse.json());
|
|
255
|
+
if (!claims.sub || !claims.iss) {
|
|
256
|
+
throw new Error("SESSION_ERROR: Userinfo response missing required claims (sub, iss)");
|
|
257
|
+
}
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const expiresAt = tokenJson.expires_in ? now + tokenJson.expires_in * 1000 : undefined;
|
|
260
|
+
const tokens = {
|
|
261
|
+
accessToken: tokenJson.access_token,
|
|
262
|
+
tokenType: tokenJson.token_type,
|
|
263
|
+
refreshToken: tokenJson.refresh_token,
|
|
264
|
+
idToken: tokenJson.id_token,
|
|
265
|
+
expiresIn: tokenJson.expires_in,
|
|
266
|
+
expiresAt,
|
|
267
|
+
scope: tokenJson.scope
|
|
268
|
+
};
|
|
269
|
+
const session = {
|
|
270
|
+
provider: pending.provider,
|
|
271
|
+
issuer: resolved.issuer,
|
|
272
|
+
clientId: resolved.clientId,
|
|
273
|
+
claims,
|
|
274
|
+
tokens,
|
|
275
|
+
createdAt: now
|
|
276
|
+
};
|
|
277
|
+
resolved.localStorage.setItem(resolved.storageKey, JSON.stringify(session));
|
|
278
|
+
resolved.sessionStorage.removeItem(resolved.pendingStorageKey);
|
|
279
|
+
if (resolved.onSessionReady) {
|
|
280
|
+
await resolved.onSessionReady(claims, tokens, session);
|
|
281
|
+
}
|
|
282
|
+
return session;
|
|
283
|
+
}
|
|
284
|
+
//# sourceMappingURL=AuthentikOidcClient.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Authentik callback handler with blocking provisioning support.
|
|
3
|
+
*
|
|
4
|
+
* This module handles the OIDC authorization code exchange and optionally
|
|
5
|
+
* calls a provisioning adapter **before** allowing the app to redirect
|
|
6
|
+
* into the protected product.
|
|
7
|
+
*
|
|
8
|
+
* Reference: CIG apps/dashboard/app/auth/callback/page.tsx
|
|
9
|
+
*/
|
|
10
|
+
import type { AuthentikCallbackConfig, AuthentikCallbackResult, AuthentikTokenResponse, AuthentikClaims, ProvisioningAdapter, ProvisioningResult } from "./types";
|
|
11
|
+
/**
|
|
12
|
+
* Exchange an authorization code for tokens using PKCE.
|
|
13
|
+
*
|
|
14
|
+
* This is a pure server-safe function — it does not touch sessionStorage.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exchangeCode(config: AuthentikCallbackConfig, code: string, codeVerifier: string): Promise<AuthentikTokenResponse>;
|
|
17
|
+
/**
|
|
18
|
+
* Fetch OIDC claims from the Authentik userinfo endpoint.
|
|
19
|
+
*/
|
|
20
|
+
export declare function fetchClaims(config: AuthentikCallbackConfig, accessToken: string): Promise<AuthentikClaims>;
|
|
21
|
+
/**
|
|
22
|
+
* Options for the full callback handler.
|
|
23
|
+
*/
|
|
24
|
+
export interface ProcessCallbackOptions {
|
|
25
|
+
/** Callback config (issuer, clientId, redirectUri, etc.). */
|
|
26
|
+
config: AuthentikCallbackConfig;
|
|
27
|
+
/** The authorization code from the callback query string. */
|
|
28
|
+
code: string;
|
|
29
|
+
/** The PKCE code verifier stored by the relay or startOAuthFlow. */
|
|
30
|
+
codeVerifier: string;
|
|
31
|
+
/** The state token from the callback query string. */
|
|
32
|
+
state: string;
|
|
33
|
+
/** The expected state token (from sessionStorage / relay). */
|
|
34
|
+
expectedState: string;
|
|
35
|
+
/** The social-login provider that initiated the flow. */
|
|
36
|
+
provider: string;
|
|
37
|
+
/**
|
|
38
|
+
* Optional provisioning adapter.
|
|
39
|
+
* When provided the callback will **block** until provisioning succeeds.
|
|
40
|
+
* If provisioning fails the result will contain the error.
|
|
41
|
+
*/
|
|
42
|
+
provisioningAdapter?: ProvisioningAdapter;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Result of the full callback flow.
|
|
46
|
+
*/
|
|
47
|
+
export interface ProcessCallbackResult {
|
|
48
|
+
/** Whether the entire flow (exchange + provisioning) succeeded. */
|
|
49
|
+
success: boolean;
|
|
50
|
+
/** Tokens + claims from the exchange step. */
|
|
51
|
+
callbackResult?: AuthentikCallbackResult;
|
|
52
|
+
/** Provisioning result (only present when an adapter was provided). */
|
|
53
|
+
provisioningResult?: ProvisioningResult;
|
|
54
|
+
/** Error message on failure. */
|
|
55
|
+
error?: string;
|
|
56
|
+
/** Machine-readable error code on failure. */
|
|
57
|
+
errorCode?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Process an Authentik OIDC callback end-to-end:
|
|
61
|
+
*
|
|
62
|
+
* 1. Validate state matches
|
|
63
|
+
* 2. Exchange the authorization code for tokens
|
|
64
|
+
* 3. Fetch OIDC claims from userinfo
|
|
65
|
+
* 4. (Optional) Run the provisioning adapter — blocks until complete
|
|
66
|
+
* 5. Return the combined result
|
|
67
|
+
*
|
|
68
|
+
* If any step fails the function returns `{ success: false, error }`.
|
|
69
|
+
* It does **not** throw so that callers can present structured error UI.
|
|
70
|
+
*/
|
|
71
|
+
export declare function processCallback(options: ProcessCallbackOptions): Promise<ProcessCallbackResult>;
|