base-idp 1.0.1 → 1.1.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.
@@ -0,0 +1,13 @@
1
+ import type { BaseIdpPublicKeySet, VerifiedPrincipal, VerifyAccessTokenOptions } from "./types.js";
2
+ /**
3
+ * Verify a Base IDP PASETO v4.public token using the Web Crypto API.
4
+ *
5
+ * Unlike the server-side verifyPasetoV4Public in paseto.ts this function is
6
+ * fully async because crypto.subtle.verify is always asynchronous.
7
+ */
8
+ export declare function verifyPasetoV4PublicBrowser(token: string, keySet: BaseIdpPublicKeySet, config: {
9
+ issuer: string;
10
+ audience?: string;
11
+ requiredScope?: string;
12
+ }, options?: VerifyAccessTokenOptions): Promise<VerifiedPrincipal>;
13
+ //# sourceMappingURL=browser-paseto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-paseto.d.ts","sourceRoot":"","sources":["../src/browser-paseto.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAEV,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAWpB;;;;;GAKG;AACH,wBAAsB,2BAA2B,CAC/C,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,mBAAmB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAE,EACrE,OAAO,GAAE,wBAA6B,GACrC,OAAO,CAAC,iBAAiB,CAAC,CAqF5B"}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Browser-compatible PASETO v4.public verifier.
3
+ *
4
+ * Identical protocol to paseto.ts but uses Web Crypto API (crypto.subtle)
5
+ * instead of node:crypto so it can be bundled into browser applications.
6
+ * Ed25519 verification via crypto.subtle requires Chrome 113+, Firefox 129+,
7
+ * Safari 17+ (all major modern browsers).
8
+ */
9
+ import { base64UrlDecode, concatBytes, utf8Decode, utf8Encode } from "./base64url.js";
10
+ import { idpError } from "./errors.js";
11
+ const HEADER = utf8Encode("v4.public.");
12
+ const IMPLICIT_ASSERTION = utf8Encode("square-experience:idp:access:v1");
13
+ /**
14
+ * Verify a Base IDP PASETO v4.public token using the Web Crypto API.
15
+ *
16
+ * Unlike the server-side verifyPasetoV4Public in paseto.ts this function is
17
+ * fully async because crypto.subtle.verify is always asynchronous.
18
+ */
19
+ export async function verifyPasetoV4PublicBrowser(token, keySet, config, options = {}) {
20
+ const parts = token.split(".");
21
+ if (parts.length !== 4 || parts[0] !== "v4" || parts[1] !== "public") {
22
+ throw idpError("invalid_token", "token is not PASETO v4.public");
23
+ }
24
+ const payload = base64UrlDecode(parts[2]);
25
+ const footerBytes = base64UrlDecode(parts[3]);
26
+ if (payload.length <= 64) {
27
+ throw idpError("invalid_token", "PASETO payload is too short");
28
+ }
29
+ const footer = JSON.parse(utf8Decode(footerBytes));
30
+ if (footer.alg !== "v4.public" || footer.typ !== "paseto" || !footer.kid) {
31
+ throw idpError("invalid_token", "PASETO footer is not a Base IDP v4.public footer");
32
+ }
33
+ const publicKey = keySet.keys.find((key) => key.kid === footer.kid && key.alg === "v4.public");
34
+ if (!publicKey) {
35
+ throw idpError("unknown_kid", "PASETO key id is not in the Base IDP key set");
36
+ }
37
+ const message = payload.subarray(0, payload.length - 64);
38
+ const signature = payload.subarray(payload.length - 64);
39
+ const rawPublicKey = base64UrlDecode(publicKey.public_key_base64);
40
+ if (rawPublicKey.length !== 32 || signature.length !== 64) {
41
+ throw idpError("invalid_key", "Ed25519 public key or signature has an unexpected size");
42
+ }
43
+ // Build the Pre-Authentication Encoding (PAE) message
44
+ const pae = preAuthEncode([HEADER, message, footerBytes, IMPLICIT_ASSERTION]);
45
+ // Verify using the Web Crypto API Ed25519 (no node:crypto dependency)
46
+ let valid;
47
+ try {
48
+ const cryptoKey = await crypto.subtle.importKey("raw", rawPublicKey.buffer.slice(rawPublicKey.byteOffset, rawPublicKey.byteOffset + rawPublicKey.byteLength), { name: "Ed25519" }, false, ["verify"]);
49
+ const paeBuffer = pae.buffer.slice(pae.byteOffset, pae.byteOffset + pae.byteLength);
50
+ const sigBuffer = signature.buffer.slice(signature.byteOffset, signature.byteOffset + signature.byteLength);
51
+ valid = await crypto.subtle.verify("Ed25519", cryptoKey, sigBuffer, paeBuffer);
52
+ }
53
+ catch {
54
+ throw idpError("invalid_key", "Ed25519 key import or verification failed");
55
+ }
56
+ if (!valid) {
57
+ throw idpError("invalid_signature", "PASETO signature verification failed");
58
+ }
59
+ const claims = JSON.parse(utf8Decode(message));
60
+ validateClaims(claims, {
61
+ issuer: options.issuer ?? config.issuer,
62
+ audience: options.audience ?? config.audience ?? "square-experience",
63
+ requiredScope: options.requiredScope ?? config.requiredScope,
64
+ maxClockSkewSeconds: options.maxClockSkewSeconds ?? 30,
65
+ });
66
+ return {
67
+ id: claims.gid,
68
+ subject: claims.sub,
69
+ email: claims.email,
70
+ name: claims.name,
71
+ role: claims.role,
72
+ scopes: claims.scp ?? [],
73
+ accountContext: claims.ctx,
74
+ claims,
75
+ };
76
+ }
77
+ function validateClaims(claims, options) {
78
+ if (claims.token_use !== "access") {
79
+ throw idpError("invalid_claims", "token_use must be 'access'");
80
+ }
81
+ if (claims.iss !== options.issuer || claims.aud !== options.audience) {
82
+ throw idpError("invalid_claims", "issuer or audience mismatch");
83
+ }
84
+ const now = Date.now();
85
+ const skewMs = options.maxClockSkewSeconds * 1000;
86
+ if (Date.parse(claims.exp) <= now - skewMs) {
87
+ throw idpError("token_expired", "access token has expired");
88
+ }
89
+ if (Date.parse(claims.nbf) > now + skewMs) {
90
+ throw idpError("token_not_yet_valid", "access token is not yet valid");
91
+ }
92
+ if (options.requiredScope && !(claims.scp ?? []).includes(options.requiredScope)) {
93
+ throw idpError("insufficient_scope", `required scope '${options.requiredScope}' is missing`);
94
+ }
95
+ if (!claims.gid || !claims.sub || !claims.sid || !claims.ctx || !claims.role) {
96
+ throw idpError("invalid_claims", "required identity claims are missing from the token");
97
+ }
98
+ }
99
+ // Pre-Authentication Encoding as defined by PASETO spec
100
+ function preAuthEncode(pieces) {
101
+ const out = [uint64le(pieces.length)];
102
+ for (const piece of pieces) {
103
+ out.push(uint64le(piece.length), piece);
104
+ }
105
+ return concatBytes(out);
106
+ }
107
+ function uint64le(value) {
108
+ const out = new Uint8Array(8);
109
+ let current = BigInt(value);
110
+ for (let i = 0; i < 8; i++) {
111
+ out[i] = Number(current & 0xffn);
112
+ current >>= 8n;
113
+ }
114
+ return out;
115
+ }
116
+ //# sourceMappingURL=browser-paseto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-paseto.js","sourceRoot":"","sources":["../src/browser-paseto.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Browser Client SDK — BrowserBaseIdpClient
3
+ *
4
+ * This is the client-side entry point for every browser app.
5
+ * It owns the complete auth flow:
6
+ *
7
+ * 1. loginWithRedirect() — generates PKCE, stores state in sessionStorage, redirects to Base IDP
8
+ * 2. handleRedirectCallback() — validates state, exchanges code, verifies token, stores session
9
+ * 3. getSession() / getUser() / isAuthenticated() — synchronous session access
10
+ * 4. getAccessToken() — returns a live token, refreshes transparently if near expiry
11
+ * 5. refreshSession() — explicit refresh using the stored refresh token
12
+ * 6. logout() — clears session, optionally redirects
13
+ * 7. onSessionChange() — reactive session event subscription
14
+ *
15
+ * Zero Node.js dependencies. Runs in any modern browser or Deno/Cloudflare Workers.
16
+ */
17
+ import { BaseIdPClient } from "./client.js";
18
+ import type { AuthSession, BrowserBaseIdpConfig, LoginWithRedirectOptions, SessionChangeListener, VerifiedPrincipal, VerifyAccessTokenOptions } from "./types.js";
19
+ export type HandleCallbackResult = {
20
+ principal: VerifiedPrincipal;
21
+ /** The `returnTo` path that was saved before the redirect, if any */
22
+ returnTo?: string;
23
+ };
24
+ export declare class BrowserBaseIdpClient extends BaseIdPClient {
25
+ private readonly browserCfg;
26
+ private readonly persistSession;
27
+ private readonly prefix;
28
+ private session;
29
+ private readonly listeners;
30
+ private refreshTimer;
31
+ constructor(browserCfg: BrowserBaseIdpConfig);
32
+ /**
33
+ * Discover and cache the client configuration from Base IDP.
34
+ * Call this once on app start before using any other method.
35
+ */
36
+ init(): Promise<void>;
37
+ /**
38
+ * Start the PKCE authorization code flow.
39
+ *
40
+ * - Generates a fresh code_verifier + S256 challenge
41
+ * - Stores `{ state, codeVerifier, redirectUri, returnTo }` in sessionStorage
42
+ * - Redirects the browser to Base IDP's authorization endpoint
43
+ *
44
+ * Call `handleRedirectCallback()` on the redirect URI page to complete login.
45
+ */
46
+ loginWithRedirect(options?: LoginWithRedirectOptions): Promise<void>;
47
+ /**
48
+ * Handle the OAuth2 redirect callback.
49
+ *
50
+ * Reads `code` and `state` from the URL (or the `url` argument),
51
+ * validates state against the pending transaction in sessionStorage,
52
+ * exchanges the code for tokens, verifies the access token, and
53
+ * stores the resulting session.
54
+ *
55
+ * Returns `{ principal, returnTo }` so the app can navigate to the
56
+ * original destination.
57
+ */
58
+ handleRedirectCallback(url?: string): Promise<HandleCallbackResult>;
59
+ /**
60
+ * Synchronously return the current session, or null if not authenticated.
61
+ */
62
+ getSession(): AuthSession | null;
63
+ /**
64
+ * Synchronously return the current user (VerifiedPrincipal), or null.
65
+ */
66
+ getUser(): VerifiedPrincipal | null;
67
+ /**
68
+ * Returns true if there is a session whose access token has not yet expired.
69
+ */
70
+ isAuthenticated(): boolean;
71
+ /**
72
+ * Return a live access token, transparently refreshing it if it is within
73
+ * REFRESH_THRESHOLD_MS (60 s) of expiry.
74
+ *
75
+ * Throws `not_authenticated` if there is no session at all.
76
+ * Throws `session_expired` if the refresh also fails.
77
+ */
78
+ getAccessToken(options?: Pick<VerifyAccessTokenOptions, "requiredScope">): Promise<string>;
79
+ /**
80
+ * Force a token refresh using the stored refresh token.
81
+ * Updates the in-memory (and optionally persisted) session.
82
+ */
83
+ refreshSession(): Promise<AuthSession>;
84
+ /**
85
+ * Clear the session and optionally redirect to a post-logout URL.
86
+ */
87
+ logout(options?: {
88
+ returnTo?: string;
89
+ }): void;
90
+ /**
91
+ * Subscribe to session lifecycle events (`signed_in`, `signed_out`,
92
+ * `refreshed`, `expired`).
93
+ *
94
+ * Returns an unsubscribe function.
95
+ *
96
+ * @example
97
+ * const off = auth.onSessionChange(({ type, session }) => {
98
+ * if (type === "signed_out") router.push("/login");
99
+ * });
100
+ * // Later:
101
+ * off();
102
+ */
103
+ onSessionChange(listener: SessionChangeListener): () => void;
104
+ private verifyTokenBrowser;
105
+ private storeSession;
106
+ private clearSession;
107
+ private loadPersistedSession;
108
+ private scheduleRefresh;
109
+ private toPublicSession;
110
+ private emit;
111
+ }
112
+ /**
113
+ * Create a browser auth client from config.
114
+ *
115
+ * @example
116
+ * // Vite / Vanilla browser app
117
+ * const auth = createBrowserBaseIdpAuth({
118
+ * key: import.meta.env.VITE_BASE_IDP_KEY,
119
+ * issuer: import.meta.env.VITE_BASE_IDP_ISSUER,
120
+ * persistSession: true,
121
+ * });
122
+ *
123
+ * // On app load
124
+ * await auth.init();
125
+ *
126
+ * // Start login
127
+ * await auth.loginWithRedirect({ returnTo: "/dashboard" });
128
+ *
129
+ * // On the callback page
130
+ * const { principal, returnTo } = await auth.handleRedirectCallback();
131
+ */
132
+ export declare function createBrowserBaseIdpAuth(config: BrowserBaseIdpConfig): BrowserBaseIdpClient;
133
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAI5C,OAAO,KAAK,EACV,WAAW,EACX,oBAAoB,EACpB,wBAAwB,EAExB,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAwBpB,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,iBAAiB,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,qBAAa,oBAAqB,SAAQ,aAAa;IAOzC,OAAO,CAAC,QAAQ,CAAC,UAAU;IANvC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAU;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,OAAO,CAAgC;IAC/C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyC;IACnE,OAAO,CAAC,YAAY,CAA8C;gBAErC,UAAU,EAAE,oBAAoB;IAqB7D;;;OAGG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B;;;;;;;;OAQG;IACG,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,GAAG,OAAO,CAAC,IAAI,CAAC;IAsD9E;;;;;;;;;;OAUG;IACG,sBAAsB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC;IAuDzE;;OAEG;IACH,UAAU,IAAI,WAAW,GAAG,IAAI;IAShC;;OAEG;IACH,OAAO,IAAI,iBAAiB,GAAG,IAAI;IAInC;;OAEG;IACH,eAAe,IAAI,OAAO;IAI1B;;;;;;OAMG;IACG,cAAc,CAAC,OAAO,GAAE,IAAI,CAAC,wBAAwB,EAAE,eAAe,CAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAuBpG;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC;IA2B5C;;OAEG;IACH,MAAM,CAAC,OAAO,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,IAAI;IASjD;;;;;;;;;;;;OAYG;IACH,eAAe,CAAC,QAAQ,EAAE,qBAAqB,GAAG,MAAM,IAAI;YAS9C,kBAAkB;IAShC,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,YAAY;IAmBpB,OAAO,CAAC,oBAAoB;IAc5B,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,IAAI;CASb;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,CAE3F"}
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Browser Client SDK — BrowserBaseIdpClient
3
+ *
4
+ * This is the client-side entry point for every browser app.
5
+ * It owns the complete auth flow:
6
+ *
7
+ * 1. loginWithRedirect() — generates PKCE, stores state in sessionStorage, redirects to Base IDP
8
+ * 2. handleRedirectCallback() — validates state, exchanges code, verifies token, stores session
9
+ * 3. getSession() / getUser() / isAuthenticated() — synchronous session access
10
+ * 4. getAccessToken() — returns a live token, refreshes transparently if near expiry
11
+ * 5. refreshSession() — explicit refresh using the stored refresh token
12
+ * 6. logout() — clears session, optionally redirects
13
+ * 7. onSessionChange() — reactive session event subscription
14
+ *
15
+ * Zero Node.js dependencies. Runs in any modern browser or Deno/Cloudflare Workers.
16
+ */
17
+ import { BaseIdPClient } from "./client.js";
18
+ import { generatePKCE } from "./pkce.js";
19
+ import { idpError } from "./errors.js";
20
+ import { verifyPasetoV4PublicBrowser } from "./browser-paseto.js";
21
+ // How many milliseconds before expiry we proactively refresh the token.
22
+ const REFRESH_THRESHOLD_MS = 60_000;
23
+ export class BrowserBaseIdpClient extends BaseIdPClient {
24
+ browserCfg;
25
+ persistSession;
26
+ prefix;
27
+ session = null;
28
+ listeners = new Set();
29
+ refreshTimer = null;
30
+ constructor(browserCfg) {
31
+ super(browserCfg);
32
+ this.browserCfg = browserCfg;
33
+ this.persistSession = browserCfg.persistSession ?? false;
34
+ this.prefix = browserCfg.storagePrefix ?? "base_idp";
35
+ // Apply browser-specific overrides on top of the base config
36
+ if (browserCfg.redirectUri)
37
+ this.cfg.redirectUri = browserCfg.redirectUri;
38
+ if (browserCfg.audience)
39
+ this.cfg.audience = browserCfg.audience;
40
+ if (browserCfg.scopes) {
41
+ this.cfg.scopes = this.scopes(browserCfg.scopes);
42
+ }
43
+ // Eagerly restore any persisted session from storage
44
+ this.session = this.loadPersistedSession();
45
+ if (this.session) {
46
+ this.scheduleRefresh();
47
+ }
48
+ }
49
+ // ── Initialise ─────────────────────────────────────────────────────────────
50
+ /**
51
+ * Discover and cache the client configuration from Base IDP.
52
+ * Call this once on app start before using any other method.
53
+ */
54
+ async init() {
55
+ await this.resolveConfig();
56
+ if (!this.session) {
57
+ this.session = this.loadPersistedSession();
58
+ if (this.session)
59
+ this.scheduleRefresh();
60
+ }
61
+ }
62
+ // ── Login ──────────────────────────────────────────────────────────────────
63
+ /**
64
+ * Start the PKCE authorization code flow.
65
+ *
66
+ * - Generates a fresh code_verifier + S256 challenge
67
+ * - Stores `{ state, codeVerifier, redirectUri, returnTo }` in sessionStorage
68
+ * - Redirects the browser to Base IDP's authorization endpoint
69
+ *
70
+ * Call `handleRedirectCallback()` on the redirect URI page to complete login.
71
+ */
72
+ async loginWithRedirect(options = {}) {
73
+ await this.resolveConfig();
74
+ // Confidential clients require a client_secret for code exchange.
75
+ // Secrets must never appear in browser bundles — reject early with a
76
+ // clear developer message instead of failing silently at the token endpoint.
77
+ if (this.cfg.confidential) {
78
+ throw idpError("confidential_client_in_browser", "This client is registered as confidential (requires a client_secret). " +
79
+ "Browser apps must use a public PKCE client. " +
80
+ "Either register a new public client for this app, or route the OAuth callback " +
81
+ "through a backend server that holds the secret and calls exchangeCode() there.");
82
+ }
83
+ const pkce = await generatePKCE();
84
+ const state = options.state ?? generateRandom(32);
85
+ const nonce = options.nonce ?? generateRandom(16);
86
+ const redirectUri = options.redirectUri ?? this.cfg.redirectUri;
87
+ if (!redirectUri) {
88
+ throw idpError("invalid_config", "redirectUri is required — pass it in BrowserBaseIdpConfig or LoginWithRedirectOptions");
89
+ }
90
+ const tx = {
91
+ state,
92
+ codeVerifier: pkce.verifier,
93
+ redirectUri,
94
+ returnTo: options.returnTo,
95
+ nonce,
96
+ createdAt: Date.now(),
97
+ };
98
+ sessionStorage.setItem(`${this.prefix}.txs.${state}`, JSON.stringify(tx));
99
+ const url = this.authorizeUrl({
100
+ state,
101
+ nonce,
102
+ codeChallenge: pkce.challenge,
103
+ codeChallengeMethod: "S256",
104
+ redirectUri,
105
+ scopes: options.scopes ?? this.cfg.scopes,
106
+ additionalParameters: options.additionalParameters,
107
+ });
108
+ window.location.assign(url);
109
+ }
110
+ // ── Callback ───────────────────────────────────────────────────────────────
111
+ /**
112
+ * Handle the OAuth2 redirect callback.
113
+ *
114
+ * Reads `code` and `state` from the URL (or the `url` argument),
115
+ * validates state against the pending transaction in sessionStorage,
116
+ * exchanges the code for tokens, verifies the access token, and
117
+ * stores the resulting session.
118
+ *
119
+ * Returns `{ principal, returnTo }` so the app can navigate to the
120
+ * original destination.
121
+ */
122
+ async handleRedirectCallback(url) {
123
+ const searchString = url ? new URL(url).search : window.location.search;
124
+ const params = new URLSearchParams(searchString);
125
+ // Surface IDP-level errors (access_denied, etc.) before anything else
126
+ const errorParam = params.get("error");
127
+ if (errorParam) {
128
+ const desc = params.get("error_description") ?? errorParam;
129
+ throw idpError("oauth_callback_error", desc);
130
+ }
131
+ const code = params.get("code");
132
+ const state = params.get("state") ?? "";
133
+ if (!code) {
134
+ throw idpError("missing_code", "no authorization code found in the callback URL");
135
+ }
136
+ // Retrieve and remove the pending PKCE transaction
137
+ const txKey = `${this.prefix}.txs.${state}`;
138
+ const txRaw = sessionStorage.getItem(txKey);
139
+ if (!txRaw) {
140
+ throw idpError("invalid_state", "no pending auth transaction for this state value — possible CSRF or stale redirect");
141
+ }
142
+ const tx = JSON.parse(txRaw);
143
+ sessionStorage.removeItem(txKey);
144
+ // Exchange authorization code for token pair
145
+ const tokens = await this.exchangeCode({
146
+ code,
147
+ codeVerifier: tx.codeVerifier,
148
+ redirectUri: tx.redirectUri,
149
+ });
150
+ // Verify the access token using the browser-safe PASETO verifier
151
+ const principal = await this.verifyTokenBrowser(tokens.access_token);
152
+ const internal = {
153
+ principal,
154
+ accessToken: tokens.access_token,
155
+ refreshToken: tokens.refresh_token,
156
+ expiresAt: Date.now() + tokens.expires_in * 1000,
157
+ refreshExpiresAt: tokens.refresh_token_expires_at,
158
+ };
159
+ this.storeSession(internal);
160
+ return { principal, returnTo: tx.returnTo };
161
+ }
162
+ // ── Session access ─────────────────────────────────────────────────────────
163
+ /**
164
+ * Synchronously return the current session, or null if not authenticated.
165
+ */
166
+ getSession() {
167
+ if (!this.session)
168
+ return null;
169
+ return {
170
+ principal: this.session.principal,
171
+ accessToken: this.session.accessToken,
172
+ expiresAt: this.session.expiresAt,
173
+ };
174
+ }
175
+ /**
176
+ * Synchronously return the current user (VerifiedPrincipal), or null.
177
+ */
178
+ getUser() {
179
+ return this.session?.principal ?? null;
180
+ }
181
+ /**
182
+ * Returns true if there is a session whose access token has not yet expired.
183
+ */
184
+ isAuthenticated() {
185
+ return this.session !== null && this.session.expiresAt > Date.now();
186
+ }
187
+ /**
188
+ * Return a live access token, transparently refreshing it if it is within
189
+ * REFRESH_THRESHOLD_MS (60 s) of expiry.
190
+ *
191
+ * Throws `not_authenticated` if there is no session at all.
192
+ * Throws `session_expired` if the refresh also fails.
193
+ */
194
+ async getAccessToken(options = {}) {
195
+ if (!this.session) {
196
+ throw idpError("not_authenticated", "no active session — call loginWithRedirect() first");
197
+ }
198
+ if (this.session.expiresAt - Date.now() < REFRESH_THRESHOLD_MS) {
199
+ await this.refreshSession();
200
+ }
201
+ if (!this.session) {
202
+ throw idpError("session_expired", "session expired and could not be refreshed automatically");
203
+ }
204
+ if (options.requiredScope && !this.session.principal.scopes.includes(options.requiredScope)) {
205
+ throw idpError("insufficient_scope", `required scope '${options.requiredScope}' is not present in the session`);
206
+ }
207
+ return this.session.accessToken;
208
+ }
209
+ /**
210
+ * Force a token refresh using the stored refresh token.
211
+ * Updates the in-memory (and optionally persisted) session.
212
+ */
213
+ async refreshSession() {
214
+ if (!this.session?.refreshToken) {
215
+ throw idpError("not_authenticated", "no refresh token available");
216
+ }
217
+ try {
218
+ const tokens = await this.refresh({ refreshToken: this.session.refreshToken });
219
+ const principal = await this.verifyTokenBrowser(tokens.access_token);
220
+ const internal = {
221
+ principal,
222
+ accessToken: tokens.access_token,
223
+ refreshToken: tokens.refresh_token,
224
+ expiresAt: Date.now() + tokens.expires_in * 1000,
225
+ refreshExpiresAt: tokens.refresh_token_expires_at,
226
+ };
227
+ this.storeSession(internal, "refreshed");
228
+ return this.getSession();
229
+ }
230
+ catch {
231
+ this.clearSession();
232
+ throw idpError("refresh_failed", "token refresh failed — user must sign in again");
233
+ }
234
+ }
235
+ // ── Logout ─────────────────────────────────────────────────────────────────
236
+ /**
237
+ * Clear the session and optionally redirect to a post-logout URL.
238
+ */
239
+ logout(options = {}) {
240
+ this.clearSession();
241
+ if (options.returnTo) {
242
+ window.location.assign(options.returnTo);
243
+ }
244
+ }
245
+ // ── Session change events ──────────────────────────────────────────────────
246
+ /**
247
+ * Subscribe to session lifecycle events (`signed_in`, `signed_out`,
248
+ * `refreshed`, `expired`).
249
+ *
250
+ * Returns an unsubscribe function.
251
+ *
252
+ * @example
253
+ * const off = auth.onSessionChange(({ type, session }) => {
254
+ * if (type === "signed_out") router.push("/login");
255
+ * });
256
+ * // Later:
257
+ * off();
258
+ */
259
+ onSessionChange(listener) {
260
+ this.listeners.add(listener);
261
+ return () => {
262
+ this.listeners.delete(listener);
263
+ };
264
+ }
265
+ // ── Private helpers ────────────────────────────────────────────────────────
266
+ async verifyTokenBrowser(token) {
267
+ await this.resolveConfig();
268
+ const keySet = await this.publicKeys();
269
+ return verifyPasetoV4PublicBrowser(token, keySet, {
270
+ issuer: this.cfg.issuer,
271
+ audience: this.cfg.audience,
272
+ });
273
+ }
274
+ storeSession(internal, eventType = "signed_in") {
275
+ this.session = internal;
276
+ if (this.persistSession) {
277
+ try {
278
+ localStorage.setItem(`${this.prefix}.session`, JSON.stringify(internal));
279
+ }
280
+ catch {
281
+ // Storage quota exceeded or private-browsing restriction — fail silently
282
+ }
283
+ }
284
+ this.scheduleRefresh();
285
+ this.emit({ type: eventType, session: this.toPublicSession(internal) });
286
+ }
287
+ clearSession() {
288
+ this.session = null;
289
+ if (this.persistSession) {
290
+ try {
291
+ localStorage.removeItem(`${this.prefix}.session`);
292
+ }
293
+ catch {
294
+ // Ignore
295
+ }
296
+ }
297
+ if (this.refreshTimer !== null) {
298
+ clearTimeout(this.refreshTimer);
299
+ this.refreshTimer = null;
300
+ }
301
+ this.emit({ type: "signed_out", session: null });
302
+ }
303
+ loadPersistedSession() {
304
+ if (!this.persistSession)
305
+ return null;
306
+ try {
307
+ const raw = localStorage.getItem(`${this.prefix}.session`);
308
+ if (!raw)
309
+ return null;
310
+ const parsed = JSON.parse(raw);
311
+ // Accept even expired sessions so refreshSession() can still run
312
+ if (!parsed.accessToken || !parsed.refreshToken || !parsed.principal)
313
+ return null;
314
+ return parsed;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ scheduleRefresh() {
321
+ if (this.refreshTimer !== null) {
322
+ clearTimeout(this.refreshTimer);
323
+ this.refreshTimer = null;
324
+ }
325
+ if (!this.session)
326
+ return;
327
+ const msUntilExpiry = this.session.expiresAt - Date.now();
328
+ const delay = Math.max(0, msUntilExpiry - REFRESH_THRESHOLD_MS);
329
+ this.refreshTimer = setTimeout(() => {
330
+ this.refreshSession().catch(() => {
331
+ this.emit({ type: "expired", session: null });
332
+ });
333
+ }, delay);
334
+ }
335
+ toPublicSession(internal) {
336
+ return {
337
+ principal: internal.principal,
338
+ accessToken: internal.accessToken,
339
+ expiresAt: internal.expiresAt,
340
+ };
341
+ }
342
+ emit(event) {
343
+ for (const listener of this.listeners) {
344
+ try {
345
+ listener(event);
346
+ }
347
+ catch {
348
+ // Never let a subscriber error crash the auth flow
349
+ }
350
+ }
351
+ }
352
+ }
353
+ /**
354
+ * Create a browser auth client from config.
355
+ *
356
+ * @example
357
+ * // Vite / Vanilla browser app
358
+ * const auth = createBrowserBaseIdpAuth({
359
+ * key: import.meta.env.VITE_BASE_IDP_KEY,
360
+ * issuer: import.meta.env.VITE_BASE_IDP_ISSUER,
361
+ * persistSession: true,
362
+ * });
363
+ *
364
+ * // On app load
365
+ * await auth.init();
366
+ *
367
+ * // Start login
368
+ * await auth.loginWithRedirect({ returnTo: "/dashboard" });
369
+ *
370
+ * // On the callback page
371
+ * const { principal, returnTo } = await auth.handleRedirectCallback();
372
+ */
373
+ export function createBrowserBaseIdpAuth(config) {
374
+ return new BrowserBaseIdpClient(config);
375
+ }
376
+ // ── Private utils ──────────────────────────────────────────────────────────
377
+ function generateRandom(bytes) {
378
+ const arr = new Uint8Array(bytes);
379
+ crypto.getRandomValues(arr);
380
+ return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join("");
381
+ }
382
+ //# sourceMappingURL=browser.js.map