@baseworks/auth 0.2.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/dist/chunk-3NJASEF4.js +69 -0
- package/dist/chunk-6TUNNS2B.js +25 -0
- package/dist/chunk-BL74TFCV.js +23 -0
- package/dist/chunk-BMPRMOI7.js +23 -0
- package/dist/chunk-C4V5LCFA.js +47 -0
- package/dist/chunk-M7EACPIB.js +33 -0
- package/dist/chunk-VBIQJKUU.js +20 -0
- package/dist/chunk-VUB4GTMI.js +402 -0
- package/dist/cli-auth.d.ts +34 -0
- package/dist/cli-auth.js +8 -0
- package/dist/edge.d.ts +17 -0
- package/dist/edge.js +155 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +54 -0
- package/dist/oidc-human.d.ts +41 -0
- package/dist/oidc-human.js +7 -0
- package/dist/oidc.d.ts +29 -0
- package/dist/oidc.js +6 -0
- package/dist/pkce.d.ts +35 -0
- package/dist/pkce.js +8 -0
- package/dist/session.d.ts +42 -0
- package/dist/session.js +25 -0
- package/dist/token.d.ts +10 -0
- package/dist/token.js +10 -0
- package/dist/url-helpers.d.ts +11 -0
- package/dist/url-helpers.js +22 -0
- package/dist/zitadel.d.ts +28 -0
- package/dist/zitadel.js +6 -0
- package/package.json +43 -0
- package/src/cli-auth.ts +103 -0
- package/src/edge.ts +189 -0
- package/src/index.ts +39 -0
- package/src/oidc-human.ts +60 -0
- package/src/oidc.ts +57 -0
- package/src/pkce.ts +73 -0
- package/src/session.ts +538 -0
- package/src/token.ts +21 -0
- package/src/url-helpers.ts +66 -0
- package/src/zitadel.ts +56 -0
package/src/edge.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Edge/server session: Pomerium JWT assertion headers + OIDC cookie fallback.
|
|
2
|
+
// Requires: next/headers (peer dep: next).
|
|
3
|
+
|
|
4
|
+
import { headers } from "next/headers";
|
|
5
|
+
import { redirect } from "next/navigation";
|
|
6
|
+
import { getSessionFromCookies, type OidcSession } from "./session";
|
|
7
|
+
import { buildPublicUrl, normalizeUrlLike } from "./url-helpers";
|
|
8
|
+
|
|
9
|
+
export type EdgeAuthProvider = "anonymous" | "cookie" | "pomerium";
|
|
10
|
+
|
|
11
|
+
export type EdgeSession = OidcSession & {
|
|
12
|
+
groups?: string[];
|
|
13
|
+
provider: EdgeAuthProvider;
|
|
14
|
+
rawClaims?: Record<string, unknown>;
|
|
15
|
+
role?: string;
|
|
16
|
+
username?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function firstHeader(headerStore: Headers, names: string[]) {
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
const value = headerStore.get(name);
|
|
22
|
+
if (value) return value;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decodeBase64Url(value: string) {
|
|
28
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
29
|
+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
|
|
30
|
+
return Buffer.from(padded, "base64").toString("utf8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseJwtPayload(token: string): Record<string, unknown> | null {
|
|
34
|
+
try {
|
|
35
|
+
const [, payload] = token.split(".");
|
|
36
|
+
if (!payload) return null;
|
|
37
|
+
return JSON.parse(decodeBase64Url(payload)) as Record<string, unknown>;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringClaim(claims: Record<string, unknown>, keys: string[]) {
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
const value = claims[key];
|
|
46
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
const first = value.find((item) => typeof item === "string" && item.trim());
|
|
49
|
+
if (first) return first as string;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseHeaderClaim(value: string | undefined) {
|
|
56
|
+
if (!value) return undefined;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(value) as unknown;
|
|
59
|
+
if (typeof parsed === "string" && parsed.trim()) return parsed;
|
|
60
|
+
if (Array.isArray(parsed)) {
|
|
61
|
+
const first = parsed.find((item) => typeof item === "string" && item.trim());
|
|
62
|
+
if (first) return first as string;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Pomerium may forward plain header values depending on configuration.
|
|
66
|
+
}
|
|
67
|
+
return value.trim() || undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stringHeaderClaim(headerStore: Headers, names: string[]) {
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const value = parseHeaderClaim(headerStore.get(name) ?? undefined);
|
|
73
|
+
if (value) return value;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stringArrayClaim(claims: Record<string, unknown>, keys: string[]) {
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
const value = claims[key];
|
|
81
|
+
if (typeof value === "string" && value.trim()) return [value];
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
const strings = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
84
|
+
if (strings.length) return strings;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stringArrayHeaderClaim(headerStore: Headers, names: string[]) {
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
const raw = headerStore.get(name);
|
|
93
|
+
if (!raw) continue;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
96
|
+
if (typeof parsed === "string" && parsed.trim()) return [parsed];
|
|
97
|
+
if (Array.isArray(parsed)) {
|
|
98
|
+
const strings = parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
99
|
+
if (strings.length) return strings;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
const values = raw.split(",").map((v) => v.trim()).filter(Boolean);
|
|
103
|
+
if (values.length) return values;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parsePomeriumSession(headerStore: Headers): EdgeSession | null {
|
|
110
|
+
const assertion = firstHeader(headerStore, ["x-pomerium-jwt-assertion", "x-pomerium-jwt"]);
|
|
111
|
+
const claims = assertion ? parseJwtPayload(assertion) : {};
|
|
112
|
+
|
|
113
|
+
const email =
|
|
114
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-email", "x-forwarded-email"]) ??
|
|
115
|
+
stringClaim(claims ?? {}, ["email"]);
|
|
116
|
+
const subject =
|
|
117
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-sub", "x-pomerium-claim-user", "x-user-id"]) ??
|
|
118
|
+
stringClaim(claims ?? {}, ["sub", "user", "id"]);
|
|
119
|
+
const name =
|
|
120
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-name", "x-forwarded-user"]) ??
|
|
121
|
+
stringClaim(claims ?? {}, ["name", "preferred_username"]);
|
|
122
|
+
const pictureUrl =
|
|
123
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-picture"]) ??
|
|
124
|
+
stringClaim(claims ?? {}, ["picture"]);
|
|
125
|
+
const username =
|
|
126
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-username", "x-pomerium-claim-preferred-username"]) ??
|
|
127
|
+
stringClaim(claims ?? {}, ["username", "preferred_username", "user"]);
|
|
128
|
+
const groups =
|
|
129
|
+
stringArrayHeaderClaim(headerStore, ["x-pomerium-claim-groups", "x-pomerium-claim-roles"]) ??
|
|
130
|
+
stringArrayClaim(claims ?? {}, ["groups", "roles"]);
|
|
131
|
+
const role =
|
|
132
|
+
stringHeaderClaim(headerStore, ["x-pomerium-claim-role"]) ??
|
|
133
|
+
stringClaim(claims ?? {}, ["role"]);
|
|
134
|
+
|
|
135
|
+
if (!email && !subject && !name) return null;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
email,
|
|
139
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
140
|
+
groups,
|
|
141
|
+
isAuthenticated: true,
|
|
142
|
+
name,
|
|
143
|
+
pictureUrl,
|
|
144
|
+
provider: "pomerium",
|
|
145
|
+
rawClaims: claims ?? undefined,
|
|
146
|
+
role,
|
|
147
|
+
subject: subject ?? email,
|
|
148
|
+
username,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function getEdgeSession(): Promise<EdgeSession> {
|
|
153
|
+
const headerStore = await headers();
|
|
154
|
+
const pomerium = parsePomeriumSession(headerStore);
|
|
155
|
+
if (pomerium) return pomerium;
|
|
156
|
+
|
|
157
|
+
const cookieSession = await getSessionFromCookies();
|
|
158
|
+
if (cookieSession.isAuthenticated) {
|
|
159
|
+
return { ...cookieSession, provider: "cookie" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { expiresAt: 0, isAuthenticated: false, provider: "anonymous" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function getPomeriumAuthenticateUrl(redirectTo: string, origin?: string) {
|
|
166
|
+
const appUrl = origin ?? process.env.APP_PUBLIC_URL ?? process.env.NEXT_PUBLIC_SITE_URL;
|
|
167
|
+
if (!appUrl) return null;
|
|
168
|
+
const url = new URL(
|
|
169
|
+
process.env.POMERIUM_LOGIN_PATH ?? "/auth/edge/login",
|
|
170
|
+
`${normalizeUrlLike(appUrl)}/`,
|
|
171
|
+
);
|
|
172
|
+
url.searchParams.set("pomerium_redirect_uri", buildPublicUrl(redirectTo, origin));
|
|
173
|
+
return url.toString();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function handleEdgeLogin(request: Request) {
|
|
177
|
+
const url = new URL(request.url);
|
|
178
|
+
const redirectTo = url.searchParams.get("pomerium_redirect_uri") ?? "/";
|
|
179
|
+
redirect(redirectTo);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function getPomeriumSignOutUrl(redirectTo: string, origin?: string) {
|
|
183
|
+
const authUrl =
|
|
184
|
+
process.env.POMERIUM_AUTHENTICATE_URL ?? process.env.POMERIUM_AUTH_URL ?? origin;
|
|
185
|
+
if (!authUrl) return null;
|
|
186
|
+
const url = new URL("/.pomerium/sign_out", `${normalizeUrlLike(authUrl)}/`);
|
|
187
|
+
url.searchParams.set("pomerium_redirect_uri", buildPublicUrl(redirectTo, origin));
|
|
188
|
+
return url.toString();
|
|
189
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// OIDC verification (provider-agnostic)
|
|
2
|
+
export { verifyOidcToken } from './oidc';
|
|
3
|
+
export type { OidcVerifyConfig, OidcIdentity, OidcClaims } from './oidc';
|
|
4
|
+
|
|
5
|
+
// Shared OIDC → HumanContext resolver (DB-agnostic)
|
|
6
|
+
export { createOidcHumanResolver } from './oidc-human';
|
|
7
|
+
export type { OidcHumanResolverConfig } from './oidc-human';
|
|
8
|
+
|
|
9
|
+
// PKCE + authorization URL builder
|
|
10
|
+
export { generatePkce, buildOidcAuthUrl } from './pkce';
|
|
11
|
+
export type { PkceChallenge, OidcAuthUrlConfig } from './pkce';
|
|
12
|
+
|
|
13
|
+
// CLI polling flow
|
|
14
|
+
export { startCliAuth, pollCliAuth } from './cli-auth';
|
|
15
|
+
export type { CliAuthStart, CliAuthResult, PollOptions } from './cli-auth';
|
|
16
|
+
|
|
17
|
+
// Token utilities
|
|
18
|
+
export { hashToken, looksLikeJwt, stripBearer } from './token';
|
|
19
|
+
|
|
20
|
+
// URL helpers (auth/account public URLs, login/logout URL builders)
|
|
21
|
+
export {
|
|
22
|
+
normalizeUrlLike,
|
|
23
|
+
getAuthPublicUrl,
|
|
24
|
+
getAccountPublicUrl,
|
|
25
|
+
getAppBasePath,
|
|
26
|
+
getPublicSiteUrl,
|
|
27
|
+
buildPublicUrl,
|
|
28
|
+
buildAuthLoginUrl,
|
|
29
|
+
buildAuthLogoutUrl,
|
|
30
|
+
buildAccountProfileUrl,
|
|
31
|
+
} from './url-helpers';
|
|
32
|
+
|
|
33
|
+
// Next.js session + edge exports are intentionally NOT re-exported here.
|
|
34
|
+
// Use subpath imports: @baseworks/auth/session, @baseworks/auth/edge
|
|
35
|
+
// This keeps the main index Worker-safe (no next/server dependency).
|
|
36
|
+
|
|
37
|
+
// Legacy re-export — kept for backward compatibility, prefer verifyOidcToken
|
|
38
|
+
export { verifyZitadelToken } from './zitadel';
|
|
39
|
+
export type { ZitadelConfig, ZitadelIdentity, ZitadelClaims } from './zitadel';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OIDC → HumanContext resolver.
|
|
3
|
+
*
|
|
4
|
+
* Handles: JWT detection, issuer check, signature verification.
|
|
5
|
+
* DB lookup / user creation is delegated to `findOrCreate` — kept app-specific.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const resolve = createOidcHumanResolver({
|
|
9
|
+
* issuer: env.OIDC_ISSUER,
|
|
10
|
+
* audience: env.OIDC_AUDIENCE,
|
|
11
|
+
* findOrCreate: async (identity) => { /* DB upsert *\/ },
|
|
12
|
+
* })
|
|
13
|
+
* const ctx = await resolve(bearerToken)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { decodeJwt } from 'jose';
|
|
17
|
+
import { verifyOidcToken } from './oidc.js';
|
|
18
|
+
import type { OidcIdentity } from './oidc.js';
|
|
19
|
+
|
|
20
|
+
export type { OidcIdentity };
|
|
21
|
+
|
|
22
|
+
export interface OidcHumanResolverConfig<T> {
|
|
23
|
+
/** OIDC issuer URL — e.g. https://nesskey.com */
|
|
24
|
+
issuer: string;
|
|
25
|
+
/** Optional audience restriction */
|
|
26
|
+
audience?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Called after successful OIDC verification.
|
|
29
|
+
* Return the app's HumanContext or null to reject.
|
|
30
|
+
* Responsible for DB lookup / upsert.
|
|
31
|
+
* rawToken is passed so implementations can call userinfo if needed.
|
|
32
|
+
*/
|
|
33
|
+
findOrCreate: (identity: OidcIdentity, rawToken: string) => Promise<T | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns a resolver function `(token: string) => Promise<T | null>`.
|
|
38
|
+
* Call it with a raw Bearer token (JWT form). Returns null if:
|
|
39
|
+
* - token is not a JWT
|
|
40
|
+
* - issuer doesn't match
|
|
41
|
+
* - signature invalid / expired
|
|
42
|
+
* - findOrCreate returns null
|
|
43
|
+
*/
|
|
44
|
+
export function createOidcHumanResolver<T>(
|
|
45
|
+
config: OidcHumanResolverConfig<T>,
|
|
46
|
+
): (token: string) => Promise<T | null> {
|
|
47
|
+
const issuer = config.issuer.replace(/\/+$/, '');
|
|
48
|
+
|
|
49
|
+
return async (token: string): Promise<T | null> => {
|
|
50
|
+
// Fast-fail: decode without verify to check issuer
|
|
51
|
+
let iss: string | undefined;
|
|
52
|
+
try { iss = (decodeJwt(token) as { iss?: string }).iss; } catch { return null; }
|
|
53
|
+
if (!iss || !iss.startsWith(issuer)) return null;
|
|
54
|
+
|
|
55
|
+
const identity = await verifyOidcToken(token, { issuer, audience: config.audience });
|
|
56
|
+
if (!identity) return null;
|
|
57
|
+
|
|
58
|
+
return config.findOrCreate(identity, token);
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
|
|
3
|
+
export interface OidcVerifyConfig {
|
|
4
|
+
issuer: string;
|
|
5
|
+
audience?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface OidcClaims {
|
|
9
|
+
sub: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
preferred_username?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
picture?: string;
|
|
14
|
+
exp?: number;
|
|
15
|
+
iss?: string;
|
|
16
|
+
aud?: string | string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Matches OidcIdentity in @baseworks/account — intentionally compatible.
|
|
20
|
+
export interface OidcIdentity {
|
|
21
|
+
subject: string;
|
|
22
|
+
issuer: string; // normalised, no trailing slash
|
|
23
|
+
email: string;
|
|
24
|
+
name: string;
|
|
25
|
+
picture?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withoutTrailingSlash(v: string): string {
|
|
29
|
+
return v.replace(/\/+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Verify an OIDC JWT and return the normalised identity.
|
|
34
|
+
* Uses JWKS from `{issuer}/oauth/v2/keys` (Zitadel-compatible endpoint).
|
|
35
|
+
* Returns null on any verification failure — never throws to the caller.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyOidcToken(
|
|
38
|
+
token: string,
|
|
39
|
+
config: OidcVerifyConfig,
|
|
40
|
+
): Promise<OidcIdentity | null> {
|
|
41
|
+
const issuer = withoutTrailingSlash(config.issuer);
|
|
42
|
+
const jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`));
|
|
43
|
+
const opts = config.audience ? { issuer, audience: config.audience } : { issuer };
|
|
44
|
+
|
|
45
|
+
const result = await jwtVerify(token, jwks, opts).catch(() => null);
|
|
46
|
+
if (!result) return null;
|
|
47
|
+
|
|
48
|
+
const payload = result.payload as OidcClaims;
|
|
49
|
+
const subject = String(payload.sub ?? '');
|
|
50
|
+
if (!subject) return null;
|
|
51
|
+
|
|
52
|
+
const email = String(payload.email ?? payload.preferred_username ?? `${subject}@unknown`);
|
|
53
|
+
const name = String(payload.name ?? payload.preferred_username ?? email);
|
|
54
|
+
const picture = payload.picture ? String(payload.picture) : undefined;
|
|
55
|
+
|
|
56
|
+
return { subject, issuer, email, name, picture };
|
|
57
|
+
}
|
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (Proof Key for Code Exchange) utilities — RFC 7636.
|
|
3
|
+
* Runtime-agnostic: Workers, Node 20+, browser (Web Crypto API).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface PkceChallenge {
|
|
7
|
+
verifier: string;
|
|
8
|
+
challenge: string;
|
|
9
|
+
method: 'S256';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OidcAuthUrlConfig {
|
|
13
|
+
/** OIDC issuer base URL, e.g. "https://nesskey.com" */
|
|
14
|
+
issuer: string;
|
|
15
|
+
clientId: string;
|
|
16
|
+
redirectUri: string;
|
|
17
|
+
/** Defaults to ["openid", "email", "profile"] */
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
state?: string;
|
|
20
|
+
challenge: string;
|
|
21
|
+
/**
|
|
22
|
+
* OIDC prompt parameter.
|
|
23
|
+
* - "select_account" → show account chooser even if session exists (recommended for web apps)
|
|
24
|
+
* - "login" → force re-authentication every time
|
|
25
|
+
* - "none" → silent auth, error if no session
|
|
26
|
+
*/
|
|
27
|
+
prompt?: 'select_account' | 'login' | 'none' | 'consent';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Generate a PKCE code_verifier + S256 code_challenge pair. */
|
|
31
|
+
export async function generatePkce(): Promise<PkceChallenge> {
|
|
32
|
+
const array = new Uint8Array(32);
|
|
33
|
+
crypto.getRandomValues(array);
|
|
34
|
+
const verifier = base64url(array);
|
|
35
|
+
|
|
36
|
+
const encoded = new TextEncoder().encode(verifier);
|
|
37
|
+
const digest = await crypto.subtle.digest('SHA-256', encoded);
|
|
38
|
+
const challenge = base64url(new Uint8Array(digest));
|
|
39
|
+
|
|
40
|
+
return { verifier, challenge, method: 'S256' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build an OIDC authorization URL with PKCE.
|
|
45
|
+
* Compatible with any OIDC provider (Zitadel, Auth0, Keycloak, etc.).
|
|
46
|
+
*/
|
|
47
|
+
export function buildOidcAuthUrl(config: OidcAuthUrlConfig): string {
|
|
48
|
+
const issuer = config.issuer.replace(/\/+$/, '');
|
|
49
|
+
const scopes = (config.scopes ?? ['openid', 'email', 'profile']).join(' ');
|
|
50
|
+
|
|
51
|
+
const params = new URLSearchParams({
|
|
52
|
+
client_id: config.clientId,
|
|
53
|
+
redirect_uri: config.redirectUri,
|
|
54
|
+
response_type: 'code',
|
|
55
|
+
scope: scopes,
|
|
56
|
+
code_challenge: config.challenge,
|
|
57
|
+
code_challenge_method: 'S256',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (config.state) params.set('state', config.state);
|
|
61
|
+
if (config.prompt) params.set('prompt', config.prompt);
|
|
62
|
+
|
|
63
|
+
return `${issuer}/oauth/v2/authorize?${params.toString()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function base64url(buf: Uint8Array): string {
|
|
69
|
+
return btoa(Array.from(buf, (b) => String.fromCharCode(b)).join(''))
|
|
70
|
+
.replace(/\+/g, '-')
|
|
71
|
+
.replace(/\//g, '_')
|
|
72
|
+
.replace(/=/g, '');
|
|
73
|
+
}
|