@eventuras/fides-auth 0.7.1 → 0.9.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/activity-tracker.d.ts +66 -0
- package/dist/activity-tracker.d.ts.map +1 -0
- package/dist/activity-tracker.js +75 -0
- package/dist/activity-tracker.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/logger.js.map +1 -1
- package/dist/oauth-browser.js.map +1 -1
- package/dist/oauth-logging.js.map +1 -1
- package/dist/oauth.js +1 -1
- package/dist/oauth.js.map +1 -1
- package/dist/providers/vipps/index.js.map +1 -1
- package/dist/rate-limit.js.map +1 -1
- package/dist/session-refresh.js +1 -1
- package/dist/session-refresh.js.map +1 -1
- package/dist/session-validation.js.map +1 -1
- package/dist/silent-login.js.map +1 -1
- package/dist/types.d.ts +4 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.js.map +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records the most recent user activity timestamp by listening to DOM events.
|
|
3
|
+
*
|
|
4
|
+
* Designed for "heartbeat" or "keepalive" logic that should only run while the
|
|
5
|
+
* user is actually interacting with the page. The tracker itself does not poll
|
|
6
|
+
* or call any network — it only records timestamps. Consumers (e.g. a React
|
|
7
|
+
* hook) decide what to do with them.
|
|
8
|
+
*
|
|
9
|
+
* **Why DOM-level and not React?** Activity should be tracked at the window
|
|
10
|
+
* level so a keystroke in a deeply nested editor counts, without every
|
|
11
|
+
* component having to wire itself up.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const tracker = createActivityTracker();
|
|
16
|
+
*
|
|
17
|
+
* // Later, e.g. in a heartbeat tick:
|
|
18
|
+
* if (tracker.isActiveWithin(2 * 60_000)) {
|
|
19
|
+
* // user has been active in the last 2 minutes
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // On unmount:
|
|
23
|
+
* tracker.dispose();
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Default DOM events that count as user activity.
|
|
28
|
+
*
|
|
29
|
+
* Uses `focusin` rather than `focus` because `focus` does not bubble — focusing
|
|
30
|
+
* a nested input would otherwise be missed when listening on `window`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_ACTIVITY_EVENTS: readonly ["pointerdown", "keydown", "touchstart", "focusin"];
|
|
33
|
+
export interface ActivityTrackerOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Target to attach listeners to. Defaults to `globalThis.window` in the
|
|
36
|
+
* browser. Tests can pass a Node `EventTarget` to drive activity manually.
|
|
37
|
+
*/
|
|
38
|
+
target?: EventTarget;
|
|
39
|
+
/** Event names that count as activity. Defaults to {@link DEFAULT_ACTIVITY_EVENTS}. */
|
|
40
|
+
events?: readonly string[];
|
|
41
|
+
/** Minimum ms between recorded timestamps. Defaults to 1000. */
|
|
42
|
+
throttleMs?: number;
|
|
43
|
+
/** Initial activity timestamp. Defaults to `now()`. */
|
|
44
|
+
initialTimestamp?: number;
|
|
45
|
+
/** Time source, for testing. Defaults to `Date.now`. */
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
export interface ActivityTracker {
|
|
49
|
+
/** Last recorded activity timestamp (ms since epoch). */
|
|
50
|
+
getLastActivity(): number;
|
|
51
|
+
/** True if activity was recorded within the last `ms` milliseconds. */
|
|
52
|
+
isActiveWithin(ms: number): boolean;
|
|
53
|
+
/** Force-record an activity timestamp (e.g. on successful server action). */
|
|
54
|
+
recordActivity(): void;
|
|
55
|
+
/** Remove listeners. Idempotent. */
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Creates an activity tracker bound to a DOM-like event target.
|
|
60
|
+
*
|
|
61
|
+
* If no `target` is given and `globalThis.window` does not exist (e.g. SSR), no
|
|
62
|
+
* listeners are attached. Explicit `recordActivity()` calls still update the
|
|
63
|
+
* timestamp — only passive listening is skipped.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createActivityTracker(options?: ActivityTrackerOptions): ActivityTracker;
|
|
66
|
+
//# sourceMappingURL=activity-tracker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-tracker.d.ts","sourceRoot":"","sources":["../src/activity-tracker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,8DAK1B,CAAC;AAEX,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,uFAAuF;IACvF,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,eAAe,IAAI,MAAM,CAAC;IAC1B,uEAAuE;IACvE,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IACpC,6EAA6E;IAC7E,cAAc,IAAI,IAAI,CAAC;IACvB,oCAAoC;IACpC,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,GAAG,eAAe,CAyC3F"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/activity-tracker.ts
|
|
2
|
+
/**
|
|
3
|
+
* Records the most recent user activity timestamp by listening to DOM events.
|
|
4
|
+
*
|
|
5
|
+
* Designed for "heartbeat" or "keepalive" logic that should only run while the
|
|
6
|
+
* user is actually interacting with the page. The tracker itself does not poll
|
|
7
|
+
* or call any network — it only records timestamps. Consumers (e.g. a React
|
|
8
|
+
* hook) decide what to do with them.
|
|
9
|
+
*
|
|
10
|
+
* **Why DOM-level and not React?** Activity should be tracked at the window
|
|
11
|
+
* level so a keystroke in a deeply nested editor counts, without every
|
|
12
|
+
* component having to wire itself up.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const tracker = createActivityTracker();
|
|
17
|
+
*
|
|
18
|
+
* // Later, e.g. in a heartbeat tick:
|
|
19
|
+
* if (tracker.isActiveWithin(2 * 60_000)) {
|
|
20
|
+
* // user has been active in the last 2 minutes
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* // On unmount:
|
|
24
|
+
* tracker.dispose();
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Default DOM events that count as user activity.
|
|
29
|
+
*
|
|
30
|
+
* Uses `focusin` rather than `focus` because `focus` does not bubble — focusing
|
|
31
|
+
* a nested input would otherwise be missed when listening on `window`.
|
|
32
|
+
*/
|
|
33
|
+
var DEFAULT_ACTIVITY_EVENTS = [
|
|
34
|
+
"pointerdown",
|
|
35
|
+
"keydown",
|
|
36
|
+
"touchstart",
|
|
37
|
+
"focusin"
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Creates an activity tracker bound to a DOM-like event target.
|
|
41
|
+
*
|
|
42
|
+
* If no `target` is given and `globalThis.window` does not exist (e.g. SSR), no
|
|
43
|
+
* listeners are attached. Explicit `recordActivity()` calls still update the
|
|
44
|
+
* timestamp — only passive listening is skipped.
|
|
45
|
+
*/
|
|
46
|
+
function createActivityTracker(options = {}) {
|
|
47
|
+
const now = options.now ?? (() => Date.now());
|
|
48
|
+
const throttleMs = options.throttleMs ?? 1e3;
|
|
49
|
+
const events = options.events ?? DEFAULT_ACTIVITY_EVENTS;
|
|
50
|
+
const target = options.target ?? (typeof window !== "undefined" ? window : void 0);
|
|
51
|
+
let lastActivity = options.initialTimestamp ?? now();
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const recordActivity = () => {
|
|
54
|
+
if (disposed) return;
|
|
55
|
+
const t = now();
|
|
56
|
+
if (t - lastActivity >= throttleMs) lastActivity = t;
|
|
57
|
+
};
|
|
58
|
+
if (target) for (const event of events) target.addEventListener(event, recordActivity, { passive: true });
|
|
59
|
+
return {
|
|
60
|
+
getLastActivity: () => lastActivity,
|
|
61
|
+
isActiveWithin: (ms) => now() - lastActivity <= ms,
|
|
62
|
+
recordActivity: () => {
|
|
63
|
+
if (!disposed) lastActivity = now();
|
|
64
|
+
},
|
|
65
|
+
dispose: () => {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
disposed = true;
|
|
68
|
+
if (target) for (const event of events) target.removeEventListener(event, recordActivity);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { DEFAULT_ACTIVITY_EVENTS, createActivityTracker };
|
|
74
|
+
|
|
75
|
+
//# sourceMappingURL=activity-tracker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activity-tracker.js","names":[],"sources":["../src/activity-tracker.ts"],"sourcesContent":["/**\n * Records the most recent user activity timestamp by listening to DOM events.\n *\n * Designed for \"heartbeat\" or \"keepalive\" logic that should only run while the\n * user is actually interacting with the page. The tracker itself does not poll\n * or call any network — it only records timestamps. Consumers (e.g. a React\n * hook) decide what to do with them.\n *\n * **Why DOM-level and not React?** Activity should be tracked at the window\n * level so a keystroke in a deeply nested editor counts, without every\n * component having to wire itself up.\n *\n * @example\n * ```ts\n * const tracker = createActivityTracker();\n *\n * // Later, e.g. in a heartbeat tick:\n * if (tracker.isActiveWithin(2 * 60_000)) {\n * // user has been active in the last 2 minutes\n * }\n *\n * // On unmount:\n * tracker.dispose();\n * ```\n */\n\n/**\n * Default DOM events that count as user activity.\n *\n * Uses `focusin` rather than `focus` because `focus` does not bubble — focusing\n * a nested input would otherwise be missed when listening on `window`.\n */\nexport const DEFAULT_ACTIVITY_EVENTS = [\n 'pointerdown',\n 'keydown',\n 'touchstart',\n 'focusin',\n] as const;\n\nexport interface ActivityTrackerOptions {\n /**\n * Target to attach listeners to. Defaults to `globalThis.window` in the\n * browser. Tests can pass a Node `EventTarget` to drive activity manually.\n */\n target?: EventTarget;\n /** Event names that count as activity. Defaults to {@link DEFAULT_ACTIVITY_EVENTS}. */\n events?: readonly string[];\n /** Minimum ms between recorded timestamps. Defaults to 1000. */\n throttleMs?: number;\n /** Initial activity timestamp. Defaults to `now()`. */\n initialTimestamp?: number;\n /** Time source, for testing. Defaults to `Date.now`. */\n now?: () => number;\n}\n\nexport interface ActivityTracker {\n /** Last recorded activity timestamp (ms since epoch). */\n getLastActivity(): number;\n /** True if activity was recorded within the last `ms` milliseconds. */\n isActiveWithin(ms: number): boolean;\n /** Force-record an activity timestamp (e.g. on successful server action). */\n recordActivity(): void;\n /** Remove listeners. Idempotent. */\n dispose(): void;\n}\n\n/**\n * Creates an activity tracker bound to a DOM-like event target.\n *\n * If no `target` is given and `globalThis.window` does not exist (e.g. SSR), no\n * listeners are attached. Explicit `recordActivity()` calls still update the\n * timestamp — only passive listening is skipped.\n */\nexport function createActivityTracker(options: ActivityTrackerOptions = {}): ActivityTracker {\n const now = options.now ?? (() => Date.now());\n const throttleMs = options.throttleMs ?? 1000;\n const events = options.events ?? DEFAULT_ACTIVITY_EVENTS;\n const target = options.target ?? (typeof window !== 'undefined' ? window : undefined);\n\n let lastActivity = options.initialTimestamp ?? now();\n let disposed = false;\n\n const recordActivity = (): void => {\n if (disposed) return;\n const t = now();\n if (t - lastActivity >= throttleMs) {\n lastActivity = t;\n }\n };\n\n // Attach listeners (no-op when no target — SSR safe).\n if (target) {\n for (const event of events) {\n target.addEventListener(event, recordActivity, { passive: true } as AddEventListenerOptions);\n }\n }\n\n return {\n getLastActivity: () => lastActivity,\n isActiveWithin: (ms: number) => now() - lastActivity <= ms,\n recordActivity: () => {\n // Bypass throttle when called explicitly.\n if (!disposed) lastActivity = now();\n },\n dispose: () => {\n if (disposed) return;\n disposed = true;\n if (target) {\n for (const event of events) {\n target.removeEventListener(event, recordActivity);\n }\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,IAAa,0BAA0B;CACrC;CACA;CACA;CACA;AACF;;;;;;;;AAoCA,SAAgB,sBAAsB,UAAkC,CAAC,GAAoB;CAC3F,MAAM,MAAM,QAAQ,cAAc,KAAK,IAAI;CAC3C,MAAM,aAAa,QAAQ,cAAc;CACzC,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,SAAS,QAAQ,WAAW,OAAO,WAAW,cAAc,SAAS,KAAA;CAE3E,IAAI,eAAe,QAAQ,oBAAoB,IAAI;CACnD,IAAI,WAAW;CAEf,MAAM,uBAA6B;EACjC,IAAI,UAAU;EACd,MAAM,IAAI,IAAI;EACd,IAAI,IAAI,gBAAgB,YACtB,eAAe;CAEnB;CAGA,IAAI,QACF,KAAK,MAAM,SAAS,QAClB,OAAO,iBAAiB,OAAO,gBAAgB,EAAE,SAAS,KAAK,CAA4B;CAI/F,OAAO;EACL,uBAAuB;EACvB,iBAAiB,OAAe,IAAI,IAAI,gBAAgB;EACxD,sBAAsB;GAEpB,IAAI,CAAC,UAAU,eAAe,IAAI;EACpC;EACA,eAAe;GACb,IAAI,UAAU;GACd,WAAW;GACX,IAAI,QACF,KAAK,MAAM,SAAS,QAClB,OAAO,oBAAoB,OAAO,cAAc;EAGtD;CACF;AACF"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { DEFAULT_ACTIVITY_EVENTS, createActivityTracker } from "./activity-tracker.js";
|
|
1
2
|
import { configureLogger, createLogger } from "./logger.js";
|
|
2
3
|
import { getOAuthConfigLogContext, getOAuthErrorLogContext } from "./oauth-logging.js";
|
|
3
4
|
import { refreshSession } from "./session-refresh.js";
|
|
4
5
|
import { accessTokenExpires, createEncryptedJWT, decrypt, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex } from "./utils.js";
|
|
5
6
|
import { isSession, validateSessionJwt } from "./session-validation.js";
|
|
6
7
|
import "./types.js";
|
|
7
|
-
export { accessTokenExpires, configureLogger, createEncryptedJWT, createLogger, decrypt, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, refreshSession, sha256, sha512, toHex, validateSessionJwt };
|
|
8
|
+
export { DEFAULT_ACTIVITY_EVENTS, accessTokenExpires, configureLogger, createActivityTracker, createEncryptedJWT, createLogger, decrypt, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, refreshSession, sha256, sha512, toHex, validateSessionJwt };
|
package/dist/logger.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.js","names":[],"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Pluggable logger for fides-auth.\n *\n * The default writes newline-delimited JSON to `console.<level>` so output\n * is interoperable with Loki / Grafana / Datadog out of the box, even when\n * the consumer hasn't wired in a structured logger. Users can plug their\n * own backend (pino, winston, @eventuras/logger, ...) via `configureLogger()`.\n *\n * @example\n * // Use default JSONL console logger (no setup needed)\n * import { createLogger } from '@eventuras/fides-auth/logger';\n * const logger = createLogger({ namespace: 'my-app:auth' });\n * logger.info('Authentication successful');\n * // → {\"level\":\"info\",\"time\":\"…\",\"namespace\":\"my-app:auth\",\"msg\":\"Authentication successful\"}\n *\n * @example\n * // Plug in pino\n * import pino from 'pino';\n * import { configureLogger } from '@eventuras/fides-auth/logger';\n *\n * const pinoInstance = pino();\n * configureLogger({\n * create({ namespace, context }) {\n * return pinoInstance.child({ namespace, ...context });\n * },\n * });\n *\n * @example\n * // Plug in @eventuras/logger\n * import { Logger } from '@eventuras/logger';\n * import { configureLogger } from '@eventuras/fides-auth/logger';\n *\n * configureLogger({\n * create(options) {\n * return Logger.create(options);\n * },\n * });\n */\n\n/**\n * Logger interface compatible with pino, winston, and console.\n *\n * All methods accept either:\n * - A string message: `logger.info('hello')`\n * - A context object and message: `logger.info({ userId: 123 }, 'User logged in')`\n */\nexport interface FidesLogger {\n debug(data?: Record<string, unknown> | string, msg?: string): void;\n info(data?: Record<string, unknown> | string, msg?: string): void;\n warn(data?: Record<string, unknown> | string, msg?: string): void;\n error(data?: Record<string, unknown> | string, msg?: string): void;\n}\n\n/** Options for creating a logger instance. */\nexport interface FidesLoggerOptions {\n /** Namespace for log filtering (e.g., 'fides-auth:oauth') */\n namespace: string;\n /** Persistent context fields included in every log entry */\n context?: Record<string, unknown>;\n}\n\n/** Factory that creates logger instances. Implement this to integrate your preferred logger. */\nexport interface FidesLoggerFactory {\n create(options: FidesLoggerOptions): FidesLogger;\n}\n\n/**\n * Console-based logger implementation that emits newline-delimited JSON.\n *\n * Used as the default when no custom logger factory is configured. JSONL\n * keeps fides-auth's output interoperable with log shippers (Loki, Grafana,\n * Datadog, etc.) even when the consuming app hasn't wired in a structured\n * logger via `configureLogger()`. Each entry preserves the original\n * `console.<level>` call so browser devtools and CI log filters still work.\n *\n * Output shape (example):\n *\n * {\"level\":\"info\",\"time\":\"2026-04-15T19:40:57.300Z\",\"namespace\":\"fides-auth:oauth\",\"msg\":\"PKCE parameters generated\",\"codeChallenge\":\"…\"}\n *\n * Errors in the `data` field are serialized to `{ name, message, stack }`\n * so they survive `JSON.stringify`.\n */\ntype ConsoleMethod = 'debug' | 'info' | 'warn' | 'error';\n\nfunction serializeError(value: unknown): unknown {\n if (value instanceof Error) {\n return { name: value.name, message: value.message, stack: value.stack };\n }\n return value;\n}\n\n/** Field names the logger reserves so callers can't accidentally clobber them. */\nconst RESERVED_KEYS = new Set(['level', 'time', 'namespace']);\n\nfunction safeStringify(record: Record<string, unknown>): string {\n const seen = new WeakSet<object>();\n try {\n return JSON.stringify(record, (_key, value) => {\n if (value instanceof Error) return serializeError(value);\n if (typeof value === 'bigint') return value.toString();\n if (typeof value === 'object' && value !== null) {\n if (seen.has(value)) return '[Circular]';\n seen.add(value);\n }\n return value;\n });\n } catch {\n // Last-resort fallback: drop the original record entirely so a single\n // non-serializable payload can't take down the caller's request path.\n return JSON.stringify({\n level: record.level,\n time: record.time,\n namespace: record.namespace,\n msg: typeof record.msg === 'string' ? record.msg : '[unserializable log entry]',\n _serializeError: true,\n });\n }\n}\n\nclass ConsoleLogger implements FidesLogger {\n constructor(\n private readonly namespace: string,\n private readonly context?: Record<string, unknown>,\n ) { }\n\n private write(\n method: ConsoleMethod,\n level: 'debug' | 'info' | 'warn' | 'error',\n data?: Record<string, unknown> | string,\n msg?: string,\n ): void {\n // Skip noisy empty entries — match the pre-JSON ConsoleLogger, which\n // dropped calls with no data and no message.\n if (data === undefined && msg === undefined) return;\n\n // Reserved keys are set first so they appear at the front of the JSON\n // output. User-supplied fields are filtered against RESERVED_KEYS so\n // they can't clobber level/time/namespace.\n const entry: Record<string, unknown> = {\n level,\n time: new Date().toISOString(),\n namespace: this.namespace,\n };\n\n if (this.context) {\n for (const [key, value] of Object.entries(this.context)) {\n if (!RESERVED_KEYS.has(key)) entry[key] = value;\n }\n }\n\n if (typeof data === 'string') {\n entry.msg = data;\n } else {\n if (msg !== undefined) entry.msg = msg;\n if (data) {\n for (const [key, value] of Object.entries(data)) {\n if (!RESERVED_KEYS.has(key)) entry[key] = value;\n }\n }\n }\n\n if (entry.error !== undefined) {\n entry.error = serializeError(entry.error);\n }\n\n console[method](safeStringify(entry));\n }\n\n debug(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('debug', 'debug', data, msg);\n }\n\n info(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('info', 'info', data, msg);\n }\n\n warn(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('warn', 'warn', data, msg);\n }\n\n error(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('error', 'error', data, msg);\n }\n}\n\nconst defaultFactory: FidesLoggerFactory = {\n create({ namespace, context }) {\n return new ConsoleLogger(namespace, context);\n },\n};\n\nlet currentFactory: FidesLoggerFactory = defaultFactory;\nlet factoryVersion = 0;\n\n/**\n * Configure a custom logger factory for fides-auth.\n *\n * Can be called at any time — even after modules have already created loggers.\n * Existing loggers will pick up the new factory on their next log call.\n *\n * If not called, fides-auth uses a console-based logger.\n *\n * @param factory - A factory that creates logger instances\n */\nexport function configureLogger(factory: FidesLoggerFactory): void {\n currentFactory = factory;\n factoryVersion++;\n}\n\n/**\n * Create a logger instance using the configured factory.\n *\n * Returns a lazy proxy: the underlying logger is created (or re-created)\n * on first use and whenever the factory is changed via `configureLogger()`.\n * This means module-scope loggers work correctly even when `configureLogger()`\n * is called after module imports.\n *\n * @param options - Logger options including namespace and optional context\n * @returns A logger instance\n */\nexport function createLogger(options: FidesLoggerOptions): FidesLogger {\n let cached: FidesLogger | null = null;\n let cachedVersion = -1;\n\n function resolve(): FidesLogger {\n if (cached === null || cachedVersion !== factoryVersion) {\n cached = currentFactory.create(options);\n cachedVersion = factoryVersion;\n }\n return cached;\n }\n\n return {\n debug(data?, msg?) { resolve().debug(data, msg); },\n info(data?, msg?) { resolve().info(data, msg); },\n warn(data?, msg?) { resolve().warn(data, msg); },\n error(data?, msg?) { resolve().error(data, msg); },\n };\n}\n"],"mappings":";AAoFA,SAAS,eAAe,OAAyB;CAC/C,IAAI,iBAAiB,OACnB,OAAO;EAAE,MAAM,MAAM;EAAM,SAAS,MAAM;EAAS,OAAO,MAAM;EAAO;CAEzE,OAAO;;;AAIT,IAAM,gBAAgB,IAAI,IAAI;CAAC;CAAS;CAAQ;CAAY,CAAC;AAE7D,SAAS,cAAc,QAAyC;CAC9D,MAAM,uBAAO,IAAI,SAAiB;CAClC,IAAI;EACF,OAAO,KAAK,UAAU,SAAS,MAAM,UAAU;GAC7C,IAAI,iBAAiB,OAAO,OAAO,eAAe,MAAM;GACxD,IAAI,OAAO,UAAU,UAAU,OAAO,MAAM,UAAU;GACtD,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM;IAC/C,IAAI,KAAK,IAAI,MAAM,EAAE,OAAO;IAC5B,KAAK,IAAI,MAAM;;GAEjB,OAAO;IACP;SACI;EAGN,OAAO,KAAK,UAAU;GACpB,OAAO,OAAO;GACd,MAAM,OAAO;GACb,WAAW,OAAO;GAClB,KAAK,OAAO,OAAO,QAAQ,WAAW,OAAO,MAAM;GACnD,iBAAiB;GAClB,CAAC;;;AAIN,IAAM,gBAAN,MAA2C;CAEtB;CACA;CAFnB,YACE,WACA,SACA;EAFiB,KAAA,YAAA;EACA,KAAA,UAAA;;CAGnB,MACE,QACA,OACA,MACA,KACM;EAGN,IAAI,SAAS,KAAA,KAAa,QAAQ,KAAA,GAAW;EAK7C,MAAM,QAAiC;GACrC;GACA,uBAAM,IAAI,MAAM,EAAC,aAAa;GAC9B,WAAW,KAAK;GACjB;EAED,IAAI,KAAK;QACF,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,QAAQ,EACrD,IAAI,CAAC,cAAc,IAAI,IAAI,EAAE,MAAM,OAAO;;EAI9C,IAAI,OAAO,SAAS,UAClB,MAAM,MAAM;OACP;GACL,IAAI,QAAQ,KAAA,GAAW,MAAM,MAAM;GACnC,IAAI;SACG,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,EAC7C,IAAI,CAAC,cAAc,IAAI,IAAI,EAAE,MAAM,OAAO;;;EAKhD,IAAI,MAAM,UAAU,KAAA,GAClB,MAAM,QAAQ,eAAe,MAAM,MAAM;EAG3C,QAAQ,QAAQ,cAAc,MAAM,CAAC;;CAGvC,MAAM,MAAyC,KAAoB;EACjE,KAAK,MAAM,SAAS,SAAS,MAAM,IAAI;;CAGzC,KAAK,MAAyC,KAAoB;EAChE,KAAK,MAAM,QAAQ,QAAQ,MAAM,IAAI;;CAGvC,KAAK,MAAyC,KAAoB;EAChE,KAAK,MAAM,QAAQ,QAAQ,MAAM,IAAI;;CAGvC,MAAM,MAAyC,KAAoB;EACjE,KAAK,MAAM,SAAS,SAAS,MAAM,IAAI;;;AAU3C,IAAI,iBAAqC,EALvC,OAAO,EAAE,WAAW,WAAW;CAC7B,OAAO,IAAI,cAAc,WAAW,QAAQ;GAIP;AACzC,IAAI,iBAAiB;;;;;;;;;;;AAYrB,SAAgB,gBAAgB,SAAmC;CACjE,iBAAiB;CACjB;;;;;;;;;;;;;AAcF,SAAgB,aAAa,SAA0C;CACrE,IAAI,SAA6B;CACjC,IAAI,gBAAgB;CAEpB,SAAS,UAAuB;EAC9B,IAAI,WAAW,QAAQ,kBAAkB,gBAAgB;GACvD,SAAS,eAAe,OAAO,QAAQ;GACvC,gBAAgB;;EAElB,OAAO;;CAGT,OAAO;EACL,MAAM,MAAO,KAAM;GAAE,SAAS,CAAC,MAAM,MAAM,IAAI;;EAC/C,KAAK,MAAO,KAAM;GAAE,SAAS,CAAC,KAAK,MAAM,IAAI;;EAC7C,KAAK,MAAO,KAAM;GAAE,SAAS,CAAC,KAAK,MAAM,IAAI;;EAC7C,MAAM,MAAO,KAAM;GAAE,SAAS,CAAC,MAAM,MAAM,IAAI;;EAChD"}
|
|
1
|
+
{"version":3,"file":"logger.js","names":[],"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Pluggable logger for fides-auth.\n *\n * The default writes newline-delimited JSON to `console.<level>` so output\n * is interoperable with Loki / Grafana / Datadog out of the box, even when\n * the consumer hasn't wired in a structured logger. Users can plug their\n * own backend (pino, winston, @eventuras/logger, ...) via `configureLogger()`.\n *\n * @example\n * // Use default JSONL console logger (no setup needed)\n * import { createLogger } from '@eventuras/fides-auth/logger';\n * const logger = createLogger({ namespace: 'my-app:auth' });\n * logger.info('Authentication successful');\n * // → {\"level\":\"info\",\"time\":\"…\",\"namespace\":\"my-app:auth\",\"msg\":\"Authentication successful\"}\n *\n * @example\n * // Plug in pino\n * import pino from 'pino';\n * import { configureLogger } from '@eventuras/fides-auth/logger';\n *\n * const pinoInstance = pino();\n * configureLogger({\n * create({ namespace, context }) {\n * return pinoInstance.child({ namespace, ...context });\n * },\n * });\n *\n * @example\n * // Plug in @eventuras/logger\n * import { Logger } from '@eventuras/logger';\n * import { configureLogger } from '@eventuras/fides-auth/logger';\n *\n * configureLogger({\n * create(options) {\n * return Logger.create(options);\n * },\n * });\n */\n\n/**\n * Logger interface compatible with pino, winston, and console.\n *\n * All methods accept either:\n * - A string message: `logger.info('hello')`\n * - A context object and message: `logger.info({ userId: 123 }, 'User logged in')`\n */\nexport interface FidesLogger {\n debug(data?: Record<string, unknown> | string, msg?: string): void;\n info(data?: Record<string, unknown> | string, msg?: string): void;\n warn(data?: Record<string, unknown> | string, msg?: string): void;\n error(data?: Record<string, unknown> | string, msg?: string): void;\n}\n\n/** Options for creating a logger instance. */\nexport interface FidesLoggerOptions {\n /** Namespace for log filtering (e.g., 'fides-auth:oauth') */\n namespace: string;\n /** Persistent context fields included in every log entry */\n context?: Record<string, unknown>;\n}\n\n/** Factory that creates logger instances. Implement this to integrate your preferred logger. */\nexport interface FidesLoggerFactory {\n create(options: FidesLoggerOptions): FidesLogger;\n}\n\n/**\n * Console-based logger implementation that emits newline-delimited JSON.\n *\n * Used as the default when no custom logger factory is configured. JSONL\n * keeps fides-auth's output interoperable with log shippers (Loki, Grafana,\n * Datadog, etc.) even when the consuming app hasn't wired in a structured\n * logger via `configureLogger()`. Each entry preserves the original\n * `console.<level>` call so browser devtools and CI log filters still work.\n *\n * Output shape (example):\n *\n * {\"level\":\"info\",\"time\":\"2026-04-15T19:40:57.300Z\",\"namespace\":\"fides-auth:oauth\",\"msg\":\"PKCE parameters generated\",\"codeChallenge\":\"…\"}\n *\n * Errors in the `data` field are serialized to `{ name, message, stack }`\n * so they survive `JSON.stringify`.\n */\ntype ConsoleMethod = 'debug' | 'info' | 'warn' | 'error';\n\nfunction serializeError(value: unknown): unknown {\n if (value instanceof Error) {\n return { name: value.name, message: value.message, stack: value.stack };\n }\n return value;\n}\n\n/** Field names the logger reserves so callers can't accidentally clobber them. */\nconst RESERVED_KEYS = new Set(['level', 'time', 'namespace']);\n\nfunction safeStringify(record: Record<string, unknown>): string {\n const seen = new WeakSet<object>();\n try {\n return JSON.stringify(record, (_key, value) => {\n if (value instanceof Error) return serializeError(value);\n if (typeof value === 'bigint') return value.toString();\n if (typeof value === 'object' && value !== null) {\n if (seen.has(value)) return '[Circular]';\n seen.add(value);\n }\n return value;\n });\n } catch {\n // Last-resort fallback: drop the original record entirely so a single\n // non-serializable payload can't take down the caller's request path.\n return JSON.stringify({\n level: record.level,\n time: record.time,\n namespace: record.namespace,\n msg: typeof record.msg === 'string' ? record.msg : '[unserializable log entry]',\n _serializeError: true,\n });\n }\n}\n\nclass ConsoleLogger implements FidesLogger {\n constructor(\n private readonly namespace: string,\n private readonly context?: Record<string, unknown>,\n ) { }\n\n private write(\n method: ConsoleMethod,\n level: 'debug' | 'info' | 'warn' | 'error',\n data?: Record<string, unknown> | string,\n msg?: string,\n ): void {\n // Skip noisy empty entries — match the pre-JSON ConsoleLogger, which\n // dropped calls with no data and no message.\n if (data === undefined && msg === undefined) return;\n\n // Reserved keys are set first so they appear at the front of the JSON\n // output. User-supplied fields are filtered against RESERVED_KEYS so\n // they can't clobber level/time/namespace.\n const entry: Record<string, unknown> = {\n level,\n time: new Date().toISOString(),\n namespace: this.namespace,\n };\n\n if (this.context) {\n for (const [key, value] of Object.entries(this.context)) {\n if (!RESERVED_KEYS.has(key)) entry[key] = value;\n }\n }\n\n if (typeof data === 'string') {\n entry.msg = data;\n } else {\n if (msg !== undefined) entry.msg = msg;\n if (data) {\n for (const [key, value] of Object.entries(data)) {\n if (!RESERVED_KEYS.has(key)) entry[key] = value;\n }\n }\n }\n\n if (entry.error !== undefined) {\n entry.error = serializeError(entry.error);\n }\n\n console[method](safeStringify(entry));\n }\n\n debug(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('debug', 'debug', data, msg);\n }\n\n info(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('info', 'info', data, msg);\n }\n\n warn(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('warn', 'warn', data, msg);\n }\n\n error(data?: Record<string, unknown> | string, msg?: string): void {\n this.write('error', 'error', data, msg);\n }\n}\n\nconst defaultFactory: FidesLoggerFactory = {\n create({ namespace, context }) {\n return new ConsoleLogger(namespace, context);\n },\n};\n\nlet currentFactory: FidesLoggerFactory = defaultFactory;\nlet factoryVersion = 0;\n\n/**\n * Configure a custom logger factory for fides-auth.\n *\n * Can be called at any time — even after modules have already created loggers.\n * Existing loggers will pick up the new factory on their next log call.\n *\n * If not called, fides-auth uses a console-based logger.\n *\n * @param factory - A factory that creates logger instances\n */\nexport function configureLogger(factory: FidesLoggerFactory): void {\n currentFactory = factory;\n factoryVersion++;\n}\n\n/**\n * Create a logger instance using the configured factory.\n *\n * Returns a lazy proxy: the underlying logger is created (or re-created)\n * on first use and whenever the factory is changed via `configureLogger()`.\n * This means module-scope loggers work correctly even when `configureLogger()`\n * is called after module imports.\n *\n * @param options - Logger options including namespace and optional context\n * @returns A logger instance\n */\nexport function createLogger(options: FidesLoggerOptions): FidesLogger {\n let cached: FidesLogger | null = null;\n let cachedVersion = -1;\n\n function resolve(): FidesLogger {\n if (cached === null || cachedVersion !== factoryVersion) {\n cached = currentFactory.create(options);\n cachedVersion = factoryVersion;\n }\n return cached;\n }\n\n return {\n debug(data?, msg?) { resolve().debug(data, msg); },\n info(data?, msg?) { resolve().info(data, msg); },\n warn(data?, msg?) { resolve().warn(data, msg); },\n error(data?, msg?) { resolve().error(data, msg); },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth-browser.js","names":[],"sources":["../src/oauth-browser.ts"],"sourcesContent":["/**\n * Browser-compatible OAuth 2.0 + PKCE helpers\n *\n * This module provides OAuth 2.0 helpers that work in browser environments.\n * Unlike `oauth.ts` which uses openid-client (Node.js only), this uses\n * browser-native Web Crypto API for PKCE generation.\n *\n * **Use this in:**\n * - Vite/React SPAs\n * - Browser-only applications\n * - Any frontend that can't use Node.js APIs\n *\n * **Usage:**\n * ```typescript\n * import { generatePKCE, buildAuthorizationUrl, exchangeCodeForTokens }\n * from '@eventuras/fides-auth/oauth-browser';\n *\n * // 1. Start OAuth flow\n * const pkce = await generatePKCE();\n * sessionStorage.setItem('code_verifier', pkce.code_verifier);\n * sessionStorage.setItem('state', pkce.state);\n *\n * const authUrl = buildAuthorizationUrl(config, pkce);\n * window.location.href = authUrl;\n *\n * // 2. Handle callback\n * const code = new URLSearchParams(window.location.search).get('code');\n * const verifier = sessionStorage.getItem('code_verifier');\n * const tokens = await exchangeCodeForTokens(config, code, verifier);\n * ```\n */\n\n/** Configuration for browser-side OAuth / OIDC flows (no client secret). */\nexport interface OAuthConfig {\n issuer: string;\n clientId: string;\n redirect_uri: string;\n scope: string;\n}\n\n/** PKCE parameters for a browser-initiated authorization request. */\nexport interface PKCEParams {\n code_verifier: string;\n code_challenge: string;\n state: string;\n}\n\n/**\n * Generate random string for code verifier (PKCE)\n * Uses Web Crypto API (browser-compatible)\n */\nfunction generateRandomString(length: number): string {\n const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';\n const randomValues = new Uint8Array(length);\n crypto.getRandomValues(randomValues);\n return Array.from(randomValues)\n .map((x) => charset[x % charset.length])\n .join('');\n}\n\n/**\n * Generate PKCE code challenge from verifier\n * Uses Web Crypto API SHA-256\n */\nasync function generateCodeChallenge(verifier: string): Promise<string> {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const hash = await crypto.subtle.digest('SHA-256', data);\n\n // Base64URL encode\n return btoa(String.fromCharCode(...new Uint8Array(hash)))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Generate PKCE parameters for OAuth flow\n */\nexport async function generatePKCE(): Promise<PKCEParams> {\n const code_verifier = generateRandomString(128);\n const code_challenge = await generateCodeChallenge(code_verifier);\n const state = generateRandomString(32);\n\n return {\n code_verifier,\n code_challenge,\n state,\n };\n}\n\n/**\n * Build authorization URL for OAuth flow\n */\nexport function buildAuthorizationUrl(\n config: OAuthConfig,\n pkce: PKCEParams\n): string {\n const params = new URLSearchParams({\n client_id: config.clientId,\n redirect_uri: config.redirect_uri,\n response_type: 'code',\n scope: config.scope,\n state: pkce.state,\n code_challenge: pkce.code_challenge,\n code_challenge_method: 'S256',\n });\n\n return `${config.issuer}/auth?${params.toString()}`;\n}\n\n/**\n * Exchange authorization code for tokens\n */\nexport async function exchangeCodeForTokens(\n config: OAuthConfig,\n code: string,\n codeVerifier: string\n): Promise<{\n access_token: string;\n id_token: string;\n refresh_token?: string;\n expires_in: number;\n}> {\n const response = await fetch(`${config.issuer}/token`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: config.redirect_uri,\n client_id: config.clientId,\n code_verifier: codeVerifier,\n }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Token exchange failed: ${response.status} - ${error}`);\n }\n\n return response.json();\n}\n"],"mappings":";;;;;AAmDA,SAAS,qBAAqB,QAAwB;CACpD,MAAM,UAAU;CAChB,MAAM,eAAe,IAAI,WAAW,
|
|
1
|
+
{"version":3,"file":"oauth-browser.js","names":[],"sources":["../src/oauth-browser.ts"],"sourcesContent":["/**\n * Browser-compatible OAuth 2.0 + PKCE helpers\n *\n * This module provides OAuth 2.0 helpers that work in browser environments.\n * Unlike `oauth.ts` which uses openid-client (Node.js only), this uses\n * browser-native Web Crypto API for PKCE generation.\n *\n * **Use this in:**\n * - Vite/React SPAs\n * - Browser-only applications\n * - Any frontend that can't use Node.js APIs\n *\n * **Usage:**\n * ```typescript\n * import { generatePKCE, buildAuthorizationUrl, exchangeCodeForTokens }\n * from '@eventuras/fides-auth/oauth-browser';\n *\n * // 1. Start OAuth flow\n * const pkce = await generatePKCE();\n * sessionStorage.setItem('code_verifier', pkce.code_verifier);\n * sessionStorage.setItem('state', pkce.state);\n *\n * const authUrl = buildAuthorizationUrl(config, pkce);\n * window.location.href = authUrl;\n *\n * // 2. Handle callback\n * const code = new URLSearchParams(window.location.search).get('code');\n * const verifier = sessionStorage.getItem('code_verifier');\n * const tokens = await exchangeCodeForTokens(config, code, verifier);\n * ```\n */\n\n/** Configuration for browser-side OAuth / OIDC flows (no client secret). */\nexport interface OAuthConfig {\n issuer: string;\n clientId: string;\n redirect_uri: string;\n scope: string;\n}\n\n/** PKCE parameters for a browser-initiated authorization request. */\nexport interface PKCEParams {\n code_verifier: string;\n code_challenge: string;\n state: string;\n}\n\n/**\n * Generate random string for code verifier (PKCE)\n * Uses Web Crypto API (browser-compatible)\n */\nfunction generateRandomString(length: number): string {\n const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';\n const randomValues = new Uint8Array(length);\n crypto.getRandomValues(randomValues);\n return Array.from(randomValues)\n .map((x) => charset[x % charset.length])\n .join('');\n}\n\n/**\n * Generate PKCE code challenge from verifier\n * Uses Web Crypto API SHA-256\n */\nasync function generateCodeChallenge(verifier: string): Promise<string> {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const hash = await crypto.subtle.digest('SHA-256', data);\n\n // Base64URL encode\n return btoa(String.fromCharCode(...new Uint8Array(hash)))\n .replaceAll('+', '-')\n .replaceAll('/', '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Generate PKCE parameters for OAuth flow\n */\nexport async function generatePKCE(): Promise<PKCEParams> {\n const code_verifier = generateRandomString(128);\n const code_challenge = await generateCodeChallenge(code_verifier);\n const state = generateRandomString(32);\n\n return {\n code_verifier,\n code_challenge,\n state,\n };\n}\n\n/**\n * Build authorization URL for OAuth flow\n */\nexport function buildAuthorizationUrl(\n config: OAuthConfig,\n pkce: PKCEParams\n): string {\n const params = new URLSearchParams({\n client_id: config.clientId,\n redirect_uri: config.redirect_uri,\n response_type: 'code',\n scope: config.scope,\n state: pkce.state,\n code_challenge: pkce.code_challenge,\n code_challenge_method: 'S256',\n });\n\n return `${config.issuer}/auth?${params.toString()}`;\n}\n\n/**\n * Exchange authorization code for tokens\n */\nexport async function exchangeCodeForTokens(\n config: OAuthConfig,\n code: string,\n codeVerifier: string\n): Promise<{\n access_token: string;\n id_token: string;\n refresh_token?: string;\n expires_in: number;\n}> {\n const response = await fetch(`${config.issuer}/token`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code,\n redirect_uri: config.redirect_uri,\n client_id: config.clientId,\n code_verifier: codeVerifier,\n }),\n });\n\n if (!response.ok) {\n const error = await response.text();\n throw new Error(`Token exchange failed: ${response.status} - ${error}`);\n }\n\n return response.json();\n}\n"],"mappings":";;;;;AAmDA,SAAS,qBAAqB,QAAwB;CACpD,MAAM,UAAU;CAChB,MAAM,eAAe,IAAI,WAAW,MAAM;CAC1C,OAAO,gBAAgB,YAAY;CACnC,OAAO,MAAM,KAAK,YAAY,EAC3B,KAAK,MAAM,QAAQ,IAAI,GAAe,EACtC,KAAK,EAAE;AACZ;;;;;AAMA,eAAe,sBAAsB,UAAmC;CAEtE,MAAM,OAAO,IADO,YACP,EAAQ,OAAO,QAAQ;CACpC,MAAM,OAAO,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI;CAGvD,OAAO,KAAK,OAAO,aAAa,GAAG,IAAI,WAAW,IAAI,CAAC,CAAC,EACrD,WAAW,KAAK,GAAG,EACnB,WAAW,KAAK,GAAG,EACnB,QAAQ,OAAO,EAAE;AACtB;;;;AAKA,eAAsB,eAAoC;CACxD,MAAM,gBAAgB,qBAAqB,GAAG;CAI9C,OAAO;EACL;EACA,gBAAA,MAL2B,sBAAsB,aAAa;EAM9D,OALY,qBAAqB,EAKjC;CACF;AACF;;;;AAKA,SAAgB,sBACd,QACA,MACQ;CACR,MAAM,SAAS,IAAI,gBAAgB;EACjC,WAAW,OAAO;EAClB,cAAc,OAAO;EACrB,eAAe;EACf,OAAO,OAAO;EACd,OAAO,KAAK;EACZ,gBAAgB,KAAK;EACrB,uBAAuB;CACzB,CAAC;CAED,OAAO,GAAG,OAAO,OAAO,QAAQ,OAAO,SAAS;AAClD;;;;AAKA,eAAsB,sBACpB,QACA,MACA,cAMC;CACD,MAAM,WAAW,MAAM,MAAM,GAAG,OAAO,OAAO,SAAS;EACrD,QAAQ;EACR,SAAS,EACP,gBAAgB,oCAClB;EACA,MAAM,IAAI,gBAAgB;GACxB,YAAY;GACZ;GACA,cAAc,OAAO;GACrB,WAAW,OAAO;GAClB,eAAe;EACjB,CAAC;CACH,CAAC;CAED,IAAI,CAAC,SAAS,IAAI;EAChB,MAAM,QAAQ,MAAM,SAAS,KAAK;EAClC,MAAM,IAAI,MAAM,0BAA0B,SAAS,OAAO,KAAK,OAAO;CACxE;CAEA,OAAO,SAAS,KAAK;AACvB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth-logging.js","names":[],"sources":["../src/oauth-logging.ts"],"sourcesContent":["import type { OAuthConfig } from './oauth';\n\n/**\n * Returns a structured, log-safe context describing an `OAuthConfig`.\n *\n * Excludes `clientSecret`. Use this to attach provider configuration to log\n * lines for debugging OAuth/OIDC flows without leaking credentials.\n */\nexport function getOAuthConfigLogContext(config: OAuthConfig) {\n return {\n clientId: config.clientId,\n issuer: config.issuer,\n redirectUri: config.redirect_uri,\n scope: config.scope,\n usePar: config.usePar ?? false,\n };\n}\n\n/**\n * Returns a structured log context for an unknown OAuth-related error.\n *\n * `oauth4webapi` (used via `openid-client`) wraps OAuth response errors. The\n * original OAuth response (`{error, error_description, ...}`) usually lives\n * either on the error itself or on `cause`.\n *\n * @see https://github.com/panva/oauth4webapi/blob/main/docs/classes/ResponseBodyError.md\n */\nexport function getOAuthErrorLogContext(error: unknown) {\n if (typeof error !== 'object' || error === null) {\n return { value: error };\n }\n\n const candidate = error as Record<string, unknown>;\n const cause =\n typeof candidate.cause === 'object' && candidate.cause !== null\n ? (candidate.cause as Record<string, unknown>)\n : null;\n\n // OAuth-specific fields (code/error/error_description) may live either on\n // the error itself or on `cause` depending on how oauth4webapi wraps the\n // response. Prefer the top-level value but fall back to `cause`.\n return {\n code: stringProp(candidate, 'code') ?? (cause ? stringProp(cause, 'code') : undefined),\n error: stringProp(candidate, 'error') ?? (cause ? stringProp(cause, 'error') : undefined),\n errorDescription:\n stringProp(candidate, 'error_description') ??\n (cause ? stringProp(cause, 'error_description') : undefined),\n message: stringProp(candidate, 'message'),\n name: stringProp(candidate, 'name'),\n status: numberProp(candidate, 'status'),\n causeMessage: cause ? stringProp(cause, 'message') : undefined,\n causeName: cause ? stringProp(cause, 'name') : undefined,\n };\n}\n\nfunction stringProp(value: Record<string, unknown>, key: string): string | undefined {\n return typeof value[key] === 'string' ? (value[key] as string) : undefined;\n}\n\nfunction numberProp(value: Record<string, unknown>, key: string): number | undefined {\n return typeof value[key] === 'number' ? (value[key] as number) : undefined;\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,yBAAyB,QAAqB;CAC5D,OAAO;EACL,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,aAAa,OAAO;EACpB,OAAO,OAAO;EACd,QAAQ,OAAO,UAAU;
|
|
1
|
+
{"version":3,"file":"oauth-logging.js","names":[],"sources":["../src/oauth-logging.ts"],"sourcesContent":["import type { OAuthConfig } from './oauth';\n\n/**\n * Returns a structured, log-safe context describing an `OAuthConfig`.\n *\n * Excludes `clientSecret`. Use this to attach provider configuration to log\n * lines for debugging OAuth/OIDC flows without leaking credentials.\n */\nexport function getOAuthConfigLogContext(config: OAuthConfig) {\n return {\n clientId: config.clientId,\n issuer: config.issuer,\n redirectUri: config.redirect_uri,\n scope: config.scope,\n usePar: config.usePar ?? false,\n };\n}\n\n/**\n * Returns a structured log context for an unknown OAuth-related error.\n *\n * `oauth4webapi` (used via `openid-client`) wraps OAuth response errors. The\n * original OAuth response (`{error, error_description, ...}`) usually lives\n * either on the error itself or on `cause`.\n *\n * @see https://github.com/panva/oauth4webapi/blob/main/docs/classes/ResponseBodyError.md\n */\nexport function getOAuthErrorLogContext(error: unknown) {\n if (typeof error !== 'object' || error === null) {\n return { value: error };\n }\n\n const candidate = error as Record<string, unknown>;\n const cause =\n typeof candidate.cause === 'object' && candidate.cause !== null\n ? (candidate.cause as Record<string, unknown>)\n : null;\n\n // OAuth-specific fields (code/error/error_description) may live either on\n // the error itself or on `cause` depending on how oauth4webapi wraps the\n // response. Prefer the top-level value but fall back to `cause`.\n return {\n code: stringProp(candidate, 'code') ?? (cause ? stringProp(cause, 'code') : undefined),\n error: stringProp(candidate, 'error') ?? (cause ? stringProp(cause, 'error') : undefined),\n errorDescription:\n stringProp(candidate, 'error_description') ??\n (cause ? stringProp(cause, 'error_description') : undefined),\n message: stringProp(candidate, 'message'),\n name: stringProp(candidate, 'name'),\n status: numberProp(candidate, 'status'),\n causeMessage: cause ? stringProp(cause, 'message') : undefined,\n causeName: cause ? stringProp(cause, 'name') : undefined,\n };\n}\n\nfunction stringProp(value: Record<string, unknown>, key: string): string | undefined {\n return typeof value[key] === 'string' ? (value[key] as string) : undefined;\n}\n\nfunction numberProp(value: Record<string, unknown>, key: string): number | undefined {\n return typeof value[key] === 'number' ? (value[key] as number) : undefined;\n}\n"],"mappings":";;;;;;;AAQA,SAAgB,yBAAyB,QAAqB;CAC5D,OAAO;EACL,UAAU,OAAO;EACjB,QAAQ,OAAO;EACf,aAAa,OAAO;EACpB,OAAO,OAAO;EACd,QAAQ,OAAO,UAAU;CAC3B;AACF;;;;;;;;;;AAWA,SAAgB,wBAAwB,OAAgB;CACtD,IAAI,OAAO,UAAU,YAAY,UAAU,MACzC,OAAO,EAAE,OAAO,MAAM;CAGxB,MAAM,YAAY;CAClB,MAAM,QACJ,OAAO,UAAU,UAAU,YAAY,UAAU,UAAU,OACtD,UAAU,QACX;CAKN,OAAO;EACL,MAAM,WAAW,WAAW,MAAM,MAAM,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;EAC5E,OAAO,WAAW,WAAW,OAAO,MAAM,QAAQ,WAAW,OAAO,OAAO,IAAI,KAAA;EAC/E,kBACE,WAAW,WAAW,mBAAmB,MACxC,QAAQ,WAAW,OAAO,mBAAmB,IAAI,KAAA;EACpD,SAAS,WAAW,WAAW,SAAS;EACxC,MAAM,WAAW,WAAW,MAAM;EAClC,QAAQ,WAAW,WAAW,QAAQ;EACtC,cAAc,QAAQ,WAAW,OAAO,SAAS,IAAI,KAAA;EACrD,WAAW,QAAQ,WAAW,OAAO,MAAM,IAAI,KAAA;CACjD;AACF;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE;AAEA,SAAS,WAAW,OAAgC,KAAiC;CACnF,OAAO,OAAO,MAAM,SAAS,WAAY,MAAM,OAAkB,KAAA;AACnE"}
|
package/dist/oauth.js
CHANGED
|
@@ -216,7 +216,7 @@ function buildSessionFromTokens(tokens, rolesClaim = "roles") {
|
|
|
216
216
|
return {
|
|
217
217
|
tokens: {
|
|
218
218
|
accessToken: tokens.access_token,
|
|
219
|
-
accessTokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3) : void 0,
|
|
219
|
+
accessTokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3).toISOString() : void 0,
|
|
220
220
|
refreshToken: tokens.refresh_token
|
|
221
221
|
},
|
|
222
222
|
user: {
|
package/dist/oauth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000)\n : undefined,\n refreshToken: tokens.refresh_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds a logout URL.\n * Returns null if the provider doesn't expose an end_session_endpoint.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param postLogoutRedirectUri - URL to redirect to after provider logout\n * @returns Logout URL or null if not supported\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n postLogoutRedirectUri: string,\n): Promise<URL | null> {\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set('client_id', oauthConfig.clientId);\n logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);\n\n logger.debug({ logoutUrl: logoutUrl.toString() }, 'Built OIDC logout URL');\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the token request fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig\n): Promise<openid.TokenEndpointResponse> {\n try {\n const params = new URLSearchParams({\n grant_type: 'client_credentials',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n });\n\n if (config.scope) {\n params.set('scope', config.scope);\n }\n\n const response = await fetch(config.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: params,\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n { status: response.status, error: errorText },\n 'Client credentials grant failed'\n );\n throw new Error(`Client credentials grant failed: ${response.status} - ${errorText}`);\n }\n\n return (await response.json()) as openid.TokenEndpointResponse;\n } catch (error) {\n logger.error({ error, tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant error');\n throw error;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"oauth.js","names":[],"sources":["../src/oauth.ts"],"sourcesContent":["import { decodeJwt } from 'jose';\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:oauth' });\n\n// Re-export commonly used types from openid-client for convenience\nexport type { Configuration, TokenEndpointResponse } from 'openid-client';\n\n/** Default OIDC scope requesting user identity, profile, email, and offline access. */\nexport const defaultScope = 'openid profile email offline_access';\n\n/** Configuration for server-side OAuth / OIDC flows. */\nexport type OAuthConfig = {\n issuer: string;\n clientId: string;\n clientSecret: string;\n redirect_uri: string;\n scope: string;\n /**\n * Opt in to Pushed Authorization Requests (RFC 9126) when the provider\n * supports it. When true, authorization parameters are posted to the\n * provider's PAR endpoint ahead of the redirect, and only `client_id` +\n * `request_uri` travel through the user-agent. The provider must advertise\n * `pushed_authorization_request_endpoint` in its discovery metadata.\n */\n usePar?: boolean;\n};\n\n/**\n * Refreshes an access token using a refresh token grant.\n *\n * @param oAuthConfig - OAuth configuration for the identity provider\n * @param refreshToken - The refresh token to exchange\n * @returns Token endpoint response containing new access and refresh tokens\n */\nexport async function refreshAccessToken(\n oAuthConfig: OAuthConfig,\n refreshToken: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug({ issuer: oAuthConfig.issuer }, 'Starting token refresh');\n\n try {\n const config = await openid.discovery(\n new URL(oAuthConfig.issuer),\n oAuthConfig.clientId,\n oAuthConfig.clientSecret,\n openid.ClientSecretPost(oAuthConfig.clientSecret),\n );\n\n const tokens = await openid.refreshTokenGrant(\n config,\n refreshToken,\n {\n scope: oAuthConfig.scope,\n },\n );\n\n logger.info({\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in\n }, 'Token refresh successful');\n\n return tokens;\n } catch (error) {\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Most often the refresh token has expired - log at info level\n logger.info(\n { ...errorContext, issuer: oAuthConfig.issuer },\n 'Token refresh failed - refresh token expired or invalid',\n );\n } else {\n // Unexpected error - log at error level\n logger.error({ error, issuer: oAuthConfig.issuer }, 'Token refresh failed');\n }\n\n throw error;\n }\n}\n\n/** PKCE parameters generated for an authorization request. */\nexport interface PKCEOptions {\n code_verifier: string;\n code_challenge: string;\n state: string;\n parameters: Record<string, string>;\n}\n\n/**\n * Creates PKCE parameters for an OAuth flow.\n *\n * @param config - OAuthConfig configuration including the redirect URI and scope.\n * @returns A promise that resolves to an object containing:\n * - code_verifier: The randomly generated PKCE code verifier.\n * - code_challenge: The derived PKCE code challenge.\n * - state: A random state to protect against CSRF.\n * - parameters: A Record<string,string> with the assembled parameters.\n */\nexport async function buildPKCEOptions(config: OAuthConfig): Promise<PKCEOptions> {\n const code_verifier = openid.randomPKCECodeVerifier();\n const code_challenge = await openid.calculatePKCECodeChallenge(code_verifier);\n const state = openid.randomState();\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope ?? defaultScope,\n code_challenge,\n code_challenge_method: 'S256',\n state,\n };\n\n logger.info(\n { redirectUri: config.redirect_uri, scope: parameters.scope },\n 'PKCE parameters generated'\n );\n\n return { code_verifier, code_challenge, state, parameters };\n}\n\n/**\n * Builds an authorization URL with PKCE parameters.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function buildAuthorizationUrl(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = openid.buildAuthorizationUrl(config, pkceOptions.parameters);\n logger.info('Authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build authorization URL');\n throw error;\n }\n}\n\n/**\n * Builds an authorization URL using Pushed Authorization Requests (RFC 9126).\n *\n * Posts the PKCE parameters to the provider's PAR endpoint and returns a URL\n * containing only `client_id` and `request_uri`. The provider's discovery\n * metadata must include `pushed_authorization_request_endpoint`.\n *\n * @param config - Discovered OpenID Configuration (use openid.discovery() to obtain)\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the PAR-based authorization URL\n */\nexport async function buildAuthorizationUrlWithPAR(\n config: openid.Configuration,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n try {\n const authUrl = await openid.buildAuthorizationUrlWithPAR(\n config,\n pkceOptions.parameters,\n );\n logger.info('PAR authorization URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error }, 'Failed to build PAR authorization URL');\n throw error;\n }\n}\n\n/**\n * Convenience function to discover OpenID configuration and build an\n * authorization URL. Routes through PAR when `oauthConfig.usePar` is true and\n * the provider advertises `pushed_authorization_request_endpoint`.\n *\n * - `usePar === true` + provider advertises PAR → uses PAR.\n * - `usePar === true` + provider does **not** advertise PAR → throws.\n * - `usePar` unset/false + provider advertises PAR → standard flow + one-line\n * `info` advisory that PAR is available but not enabled.\n * - `usePar` unset/false + no PAR endpoint → standard flow, no advisory.\n *\n * @param oauthConfig - Your OAuth configuration\n * @param pkceOptions - The PKCE options (e.g., code challenge, state, parameters)\n * @returns A Promise that resolves to the authorization URL\n */\nexport async function discoverAndBuildAuthorizationUrl(\n oauthConfig: OAuthConfig,\n pkceOptions: PKCEOptions\n): Promise<URL> {\n logger.debug({ issuer: oauthConfig.issuer }, 'Discovering OpenID configuration');\n\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret)\n );\n\n const parEndpoint = config.serverMetadata().pushed_authorization_request_endpoint;\n\n if (oauthConfig.usePar) {\n if (!parEndpoint) {\n throw new Error(\n 'PAR requested (usePar=true) but provider does not advertise pushed_authorization_request_endpoint',\n );\n }\n return buildAuthorizationUrlWithPAR(config, pkceOptions);\n }\n\n if (parEndpoint) {\n logger.info(\n { issuer: oauthConfig.issuer },\n 'Provider advertises PAR but usePar is not enabled — set OAuthConfig.usePar=true to use it',\n );\n }\n\n return buildAuthorizationUrl(config, pkceOptions);\n } catch (error) {\n logger.error({ error, issuer: oauthConfig.issuer }, 'Failed to discover config or build URL');\n throw error;\n }\n}\n\n/**\n * Configuration for OAuth client credentials flow (machine-to-machine authentication)\n */\nexport type ClientCredentialsConfig = {\n /** OAuth token endpoint URL */\n tokenEndpoint: string;\n /** OAuth client ID */\n clientId: string;\n /** OAuth client secret */\n clientSecret: string;\n /** Optional scope for the access token */\n scope?: string;\n};\n\n/**\n * Exchanges an authorization code for tokens using OIDC discovery and PKCE.\n *\n * @param oauthConfig - OAuth configuration\n * @param callbackUrl - The full callback URL with query parameters from the OIDC provider\n * @param codeVerifier - The PKCE code verifier stored during login initiation\n * @param expectedState - The state parameter stored during login initiation\n * @returns Token response from the OIDC provider\n */\nexport async function exchangeAuthorizationCode(\n oauthConfig: OAuthConfig,\n callbackUrl: URL,\n codeVerifier: string,\n expectedState: string,\n): Promise<openid.TokenEndpointResponse> {\n logger.debug(\n { issuer: oauthConfig.issuer, clientId: oauthConfig.clientId },\n 'Exchanging authorization code for tokens',\n );\n\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n\n // openid-client overrides the explicit redirect_uri option with\n // stripParams(callbackUrl). Anchor to oauthConfig.redirect_uri so PAR and\n // token-exchange stay consistent behind reverse proxies that don't forward\n // the original scheme. Build from the trusted base and then copy pathname/\n // search by assignment — `new URL(callbackUrl.pathname, base)` would let a\n // `//evil.com/...` pathname swap the host.\n const normalizedCallbackUrl = new URL(oauthConfig.redirect_uri);\n normalizedCallbackUrl.pathname = callbackUrl.pathname;\n normalizedCallbackUrl.search = callbackUrl.search;\n normalizedCallbackUrl.hash = '';\n\n const tokens = await openid.authorizationCodeGrant(\n config,\n normalizedCallbackUrl,\n {\n pkceCodeVerifier: codeVerifier,\n expectedState,\n },\n { redirect_uri: oauthConfig.redirect_uri },\n );\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Authorization code exchange successful',\n );\n\n return tokens;\n}\n\n/**\n * Result of extracting user information from an OIDC token response.\n */\nexport interface OidcUserInfo {\n sub: string;\n name: string;\n email: string;\n roles: string[];\n}\n\n/**\n * Extracts user information from an OIDC token response.\n * Decodes the ID token and extracts standard claims plus roles from a configurable claim.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns Extracted user information\n */\nexport function extractUserFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): OidcUserInfo {\n if (!tokens.id_token) {\n throw new Error('OIDC token response is missing id_token');\n }\n\n const idToken = decodeJwt(tokens.id_token);\n\n // Normalize roles: some providers send a single string instead of an array\n const rawRoles = idToken[rolesClaim];\n const nonArrayRoles = typeof rawRoles === 'string' ? [rawRoles] : [];\n const roles = Array.isArray(rawRoles) ? rawRoles : nonArrayRoles;\n\n logger.debug({ sub: idToken.sub, rolesClaim, rolesCount: roles.length }, 'Extracted user from ID token');\n\n return {\n sub: idToken.sub ?? '',\n name: idToken.name as string,\n email: idToken.email as string,\n roles,\n };\n}\n\n/**\n * Builds a Session object from an OIDC token response.\n * Maps provider tokens and user claims into the internal Session format.\n *\n * @param tokens - Token response from the OIDC provider\n * @param rolesClaim - Name of the claim containing user roles (default: 'roles')\n * @returns A Session object ready to be encrypted and stored\n */\nexport function buildSessionFromTokens(\n tokens: openid.TokenEndpointResponse,\n rolesClaim: string = 'roles',\n): Session {\n if (!tokens.access_token) {\n throw new Error('OIDC token response is missing access_token');\n }\n\n const userInfo = extractUserFromTokens(tokens, rolesClaim);\n\n let scopes: string[] | undefined;\n if (typeof tokens.scope === 'string') {\n const parsed = tokens.scope.split(' ').filter(s => s.length > 0);\n if (parsed.length > 0) scopes = parsed;\n }\n\n return {\n tokens: {\n accessToken: tokens.access_token,\n accessTokenExpiresAt: tokens.expires_in\n ? new Date(Date.now() + tokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: tokens.refresh_token,\n },\n user: {\n name: userInfo.name,\n email: userInfo.email,\n roles: userInfo.roles,\n },\n scopes,\n };\n}\n\n/**\n * Validates a return URL against open redirect attacks.\n * Ensures the URL is same-origin and passes optional custom validation.\n *\n * @param returnTo - The raw returnTo path/URL to validate\n * @param applicationOrigin - The application's origin URL (e.g., 'https://example.com')\n * @param defaultPath - Fallback path if validation fails (default: '/')\n * @param validate - Optional custom validation function for the pathname\n * @returns A safe, validated URL\n */\nexport function validateReturnUrl(\n returnTo: string | null | undefined,\n applicationOrigin: string,\n defaultPath: string = '/',\n validate?: (pathname: string) => boolean,\n): URL {\n const redirectPath = returnTo ?? defaultPath;\n let redirectUrl: URL;\n\n try {\n redirectUrl = new URL(redirectPath, applicationOrigin);\n } catch {\n logger.warn({ returnTo: redirectPath }, 'Invalid returnTo URL, using default');\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Enforce same-origin — cross-origin returnTo is a potential open redirect attack\n const originUrl = new URL(applicationOrigin);\n if (redirectUrl.origin !== originUrl.origin) {\n logger.error(\n { returnTo: redirectPath, redirectOrigin: redirectUrl.origin, applicationOrigin: originUrl.origin },\n 'Possible open redirect attack: returnTo points outside application origin',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n // Custom validation\n if (validate && !validate(redirectUrl.pathname)) {\n logger.warn(\n { pathname: redirectUrl.pathname },\n 'returnTo failed custom validation, falling back to default',\n );\n return new URL(defaultPath, applicationOrigin);\n }\n\n return redirectUrl;\n}\n\n/**\n * Discovers the OIDC provider's end_session_endpoint and builds a logout URL.\n * Returns null if the provider doesn't expose an end_session_endpoint.\n *\n * @param oauthConfig - OAuth configuration (issuer, clientId, clientSecret)\n * @param postLogoutRedirectUri - URL to redirect to after provider logout\n * @returns Logout URL or null if not supported\n */\nexport async function buildOidcLogoutUrl(\n oauthConfig: OAuthConfig,\n postLogoutRedirectUri: string,\n): Promise<URL | null> {\n try {\n const config = await openid.discovery(\n new URL(oauthConfig.issuer),\n oauthConfig.clientId,\n oauthConfig.clientSecret,\n openid.ClientSecretPost(oauthConfig.clientSecret),\n );\n const endSessionEndpoint = config.serverMetadata().end_session_endpoint;\n\n if (!endSessionEndpoint) {\n logger.info({ issuer: oauthConfig.issuer }, 'No end_session_endpoint found');\n return null;\n }\n\n const logoutUrl = new URL(endSessionEndpoint);\n logoutUrl.searchParams.set('client_id', oauthConfig.clientId);\n logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);\n\n logger.debug({ logoutUrl: logoutUrl.toString() }, 'Built OIDC logout URL');\n return logoutUrl;\n } catch (error) {\n logger.warn({ error, issuer: oauthConfig.issuer }, 'OIDC discovery failed for logout');\n return null;\n }\n}\n\n/**\n * Performs OAuth 2.0 client credentials grant flow for machine-to-machine authentication.\n *\n * @param config - Client credentials configuration including token endpoint, client ID, and secret\n * @returns Token response from the OAuth provider\n * @throws Error if the token request fails\n *\n * @example\n * ```typescript\n * const tokens = await clientCredentialsGrant({\n * tokenEndpoint: 'https://api.example.com/oauth2/token',\n * clientId: 'my-client-id',\n * clientSecret: 'my-client-secret',\n * scope: 'api:read api:write',\n * });\n * ```\n */\nexport async function clientCredentialsGrant(\n config: ClientCredentialsConfig\n): Promise<openid.TokenEndpointResponse> {\n try {\n const params = new URLSearchParams({\n grant_type: 'client_credentials',\n client_id: config.clientId,\n client_secret: config.clientSecret,\n });\n\n if (config.scope) {\n params.set('scope', config.scope);\n }\n\n const response = await fetch(config.tokenEndpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body: params,\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n { status: response.status, error: errorText },\n 'Client credentials grant failed'\n );\n throw new Error(`Client credentials grant failed: ${response.status} - ${errorText}`);\n }\n\n return (await response.json()) as openid.TokenEndpointResponse;\n } catch (error) {\n logger.error({ error, tokenEndpoint: config.tokenEndpoint }, 'Client credentials grant error');\n throw error;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/providers/vipps/types.ts","../../../src/providers/vipps/client.ts"],"sourcesContent":["/**\n * Vipps Login Provider Types\n *\n * Type definitions for Vipps Login (OIDC) integration.\n *\n * @see https://developer.vippsmobilepay.com/docs/APIs/login-api/\n */\n\n/**\n * Configuration for Vipps Login client\n */\nexport interface VippsLoginConfig {\n /** Vipps OIDC issuer URL (e.g., 'https://apitest.vipps.no/access-management-1.0/access') */\n issuer: string;\n /** OAuth Client ID from Vipps */\n clientId: string;\n /** OAuth Client Secret from Vipps */\n clientSecret: string;\n /** OAuth redirect URI (callback URL) */\n redirectUri: string;\n /** OpenID Connect scopes (default: 'openid name phoneNumber address email') */\n scope?: string;\n /** Vipps subscription key (Ocp-Apim-Subscription-Key) - required for userinfo endpoint */\n subscriptionKey?: string;\n /** Merchant serial number - required for userinfo endpoint */\n merchantSerialNumber?: string;\n}\n\n/**\n * Vipps address from userinfo endpoint\n */\nexport interface VippsAddress {\n /** Street address */\n street_address?: string;\n /** Postal code */\n postal_code?: string;\n /** Region/city */\n region?: string;\n /** Country code (e.g., 'NO') */\n country?: string;\n /** Formatted address string */\n formatted?: string;\n /** Address type (e.g., 'home', 'work', 'other') */\n address_type?: string;\n}\n\n/**\n * User information returned from Vipps Login userinfo endpoint\n *\n * @see https://developer.vippsmobilepay.com/docs/APIs/login-api/login-api-guide/#userinfo-endpoint\n */\nexport interface VippsUserInfo {\n /** Subject - unique Vipps user identifier (UUID) */\n sub: string;\n /** Email address */\n email?: string;\n /** Email verification status */\n email_verified?: boolean;\n /** Given name (first name) */\n given_name?: string;\n /** Family name (last name) */\n family_name?: string;\n /** Full name */\n name?: string;\n /** Phone number (with country code, e.g., '4712345678') */\n phone_number?: string;\n /** Phone number verification status */\n phone_number_verified?: boolean;\n /** Birth date (ISO 8601 format, e.g., '1990-01-15') */\n birthdate?: string;\n /** Physical addresses (array) */\n addresses?: VippsAddress[];\n /** Other claims (deprecated) */\n other_addresses?: VippsAddress[];\n}\n\n/**\n * Available Vipps Login scopes\n */\nexport const VippsLoginScopes = {\n /** Required: OpenID Connect base scope */\n OpenId: 'openid',\n /** User's name (given_name, family_name, name) */\n Name: 'name',\n /** Phone number */\n PhoneNumber: 'phoneNumber',\n /** Physical address */\n Address: 'address',\n /** Birth date */\n BirthDate: 'birthDate',\n /** Email address */\n Email: 'email',\n} as const;\n\n/**\n * Default scopes for Vipps Login\n */\nexport const defaultVippsLoginScope = [\n VippsLoginScopes.OpenId,\n VippsLoginScopes.Name,\n VippsLoginScopes.PhoneNumber,\n VippsLoginScopes.Address,\n VippsLoginScopes.Email,\n].join(' ');\n\n/**\n * Vipps API environments\n */\nexport const VippsEnvironments = {\n /** Test environment */\n Test: 'https://apitest.vipps.no',\n /** Production environment */\n Production: 'https://api.vipps.no',\n} as const;\n\n/**\n * Get Vipps OIDC issuer URL for environment\n */\nexport function getVippsIssuer(environment: string): string {\n // Note: Vipps metadata returns issuer with trailing slash\n return `${environment}/access-management-1.0/access/`;\n}\n","/**\n * Vipps Login Client\n *\n * Client for Vipps Login (OIDC) authentication flow.\n * Implements OAuth/OIDC operations with PKCE for Vipps.\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from '../../logger';\nimport {\n buildAuthorizationUrl,\n buildPKCEOptions,\n type OAuthConfig,\n type PKCEOptions,\n} from '../../oauth';\nimport {\n defaultVippsLoginScope,\n type VippsLoginConfig,\n type VippsUserInfo,\n} from './types';\n\nconst logger = createLogger({\n namespace: 'fides-auth:vipps',\n context: { module: 'VippsLoginClient' },\n});\n\n/**\n * Vipps Login Client\n *\n * Handles Vipps Login (OIDC) authentication flow using PKCE.\n */\nexport class VippsLoginClient {\n private readonly config: VippsLoginConfig;\n private readonly oauthConfig: OAuthConfig;\n private configPromise: Promise<openid.Configuration> | null = null;\n\n constructor(config: VippsLoginConfig) {\n this.config = {\n ...config,\n scope: config.scope ?? defaultVippsLoginScope,\n };\n\n this.oauthConfig = {\n issuer: config.issuer,\n clientId: config.clientId,\n clientSecret: config.clientSecret,\n redirect_uri: config.redirectUri,\n scope: this.config.scope!,\n };\n\n logger.info(\n {\n issuer: config.issuer,\n redirectUri: config.redirectUri,\n scope: this.config.scope,\n },\n 'VippsLoginClient initialized'\n );\n }\n\n /**\n * Get OpenID Configuration (cached)\n * This ensures we use the same Configuration instance across all operations\n */\n private async getConfig(): Promise<openid.Configuration> {\n if (!this.configPromise) {\n this.configPromise = openid.discovery(\n new URL(this.oauthConfig.issuer),\n this.oauthConfig.clientId,\n this.oauthConfig.clientSecret,\n openid.ClientSecretPost(this.oauthConfig.clientSecret)\n );\n }\n return this.configPromise;\n }\n\n /**\n * Build PKCE options for authorization flow\n *\n * @returns PKCE options including code_verifier, code_challenge, and state\n */\n async buildPKCEOptions(): Promise<PKCEOptions> {\n logger.debug('Building PKCE options');\n return buildPKCEOptions(this.oauthConfig);\n }\n\n /**\n * Build authorization URL for Vipps Login\n *\n * @param pkceOptions - PKCE options from buildPKCEOptions()\n * @returns Authorization URL to redirect user to\n */\n async buildAuthorizationUrl(pkceOptions: PKCEOptions): Promise<URL> {\n logger.debug('Building authorization URL');\n const config = await this.getConfig();\n return buildAuthorizationUrl(config, pkceOptions);\n }\n\n /**\n * Exchange authorization code for tokens\n *\n * @param callbackUrl - The full callback URL with code parameter from Vipps\n * @param codeVerifier - PKCE code verifier from initial request\n * @param expectedState - Expected state value for validation (optional)\n * @returns Token response with access_token, id_token, and refresh_token\n */\n async exchangeCodeForTokens(\n callbackUrl: string | URL,\n codeVerifier: string,\n expectedState?: string\n ): Promise<openid.TokenEndpointResponse> {\n logger.debug('Exchanging authorization code for tokens');\n\n try {\n const config = await this.getConfig();\n\n // Convert to URL if string\n const currentUrl = typeof callbackUrl === 'string' ? new URL(callbackUrl) : callbackUrl;\n\n const tokens = await openid.authorizationCodeGrant(config, currentUrl, {\n pkceCodeVerifier: codeVerifier,\n expectedState, // Pass the expected state for validation\n idTokenExpected: true,\n });\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n hasIdToken: !!tokens.id_token,\n expiresIn: tokens.expires_in,\n },\n 'Successfully exchanged code for tokens'\n );\n\n return tokens;\n } catch (error) {\n logger.error({ error }, 'Failed to exchange code for tokens');\n throw error;\n }\n }\n\n /**\n * Get user information from Vipps userinfo endpoint\n *\n * @param accessToken - Access token from token exchange\n * @returns Vipps user information\n */\n async getUserInfo(accessToken: string): Promise<VippsUserInfo> {\n logger.debug('Fetching user info from Vipps');\n\n try {\n const config = await this.getConfig();\n const userinfoUrl = config.serverMetadata().userinfo_endpoint;\n\n if (!userinfoUrl) {\n throw new Error('Userinfo endpoint not found in OpenID configuration');\n }\n\n const response = await fetch(userinfoUrl, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n ...(this.config.subscriptionKey && {\n 'Ocp-Apim-Subscription-Key': this.config.subscriptionKey,\n }),\n ...(this.config.merchantSerialNumber && {\n 'Merchant-Serial-Number': this.config.merchantSerialNumber,\n }),\n },\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n {\n status: response.status,\n error: errorText,\n },\n 'Failed to fetch user info'\n );\n throw new Error(`Failed to fetch user info: ${response.status} - ${errorText}`);\n }\n\n const userInfo = (await response.json()) as VippsUserInfo;\n\n logger.info(\n {\n sub: userInfo.sub,\n hasEmail: !!userInfo.email,\n hasPhone: !!userInfo.phone_number,\n hasAddress: !!userInfo.addresses?.length,\n },\n 'Successfully fetched user info'\n );\n\n return userInfo;\n } catch (error) {\n logger.error({ error }, 'Error fetching user info');\n throw error;\n }\n }\n\n /**\n * Refresh access token using refresh token\n *\n * @param refreshToken - Refresh token from initial token exchange\n * @returns New token response\n */\n async refreshTokens(refreshToken: string): Promise<openid.TokenEndpointResponse> {\n logger.debug('Refreshing access token');\n\n try {\n const config = await this.getConfig();\n const tokens = await openid.refreshTokenGrant(config, refreshToken);\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Successfully refreshed tokens'\n );\n\n return tokens;\n } catch (error) {\n logger.error({ error }, 'Failed to refresh tokens');\n throw error;\n }\n }\n}\n"],"mappings":";;;;;;;AA+EA,IAAa,mBAAmB;;CAE9B,QAAQ;;CAER,MAAM;;CAEN,aAAa;;CAEb,SAAS;;CAET,WAAW;;CAEX,OAAO;CACR;;;;AAKD,IAAa,yBAAyB;CACpC,iBAAiB;CACjB,iBAAiB;CACjB,iBAAiB;CACjB,iBAAiB;CACjB,iBAAiB;CAClB,CAAC,KAAK,IAAI;;;;AAKX,IAAa,oBAAoB;;CAE/B,MAAM;;CAEN,YAAY;CACb;;;;AAKD,SAAgB,eAAe,aAA6B;CAE1D,OAAO,GAAG,YAAY;;;;;;;;;;AClGxB,IAAM,SAAS,aAAa;CAC1B,WAAW;CACX,SAAS,EAAE,QAAQ,oBAAoB;CACxC,CAAC;;;;;;AAOF,IAAa,mBAAb,MAA8B;CAC5B;CACA;CACA,gBAA8D;CAE9D,YAAY,QAA0B;EACpC,KAAK,SAAS;GACZ,GAAG;GACH,OAAO,OAAO,SAAS;GACxB;EAED,KAAK,cAAc;GACjB,QAAQ,OAAO;GACf,UAAU,OAAO;GACjB,cAAc,OAAO;GACrB,cAAc,OAAO;GACrB,OAAO,KAAK,OAAO;GACpB;EAED,OAAO,KACL;GACE,QAAQ,OAAO;GACf,aAAa,OAAO;GACpB,OAAO,KAAK,OAAO;GACpB,EACD,+BACD;;;;;;CAOH,MAAc,YAA2C;EACvD,IAAI,CAAC,KAAK,eACR,KAAK,gBAAgB,OAAO,UAC1B,IAAI,IAAI,KAAK,YAAY,OAAO,EAChC,KAAK,YAAY,UACjB,KAAK,YAAY,cACjB,OAAO,iBAAiB,KAAK,YAAY,aAAa,CACvD;EAEH,OAAO,KAAK;;;;;;;CAQd,MAAM,mBAAyC;EAC7C,OAAO,MAAM,wBAAwB;EACrC,OAAO,iBAAiB,KAAK,YAAY;;;;;;;;CAS3C,MAAM,sBAAsB,aAAwC;EAClE,OAAO,MAAM,6BAA6B;EAE1C,OAAO,sBAAsB,MADR,KAAK,WAAW,EACA,YAAY;;;;;;;;;;CAWnD,MAAM,sBACJ,aACA,cACA,eACuC;EACvC,OAAO,MAAM,2CAA2C;EAExD,IAAI;GACF,MAAM,SAAS,MAAM,KAAK,WAAW;GAGrC,MAAM,aAAa,OAAO,gBAAgB,WAAW,IAAI,IAAI,YAAY,GAAG;GAE5E,MAAM,SAAS,MAAM,OAAO,uBAAuB,QAAQ,YAAY;IACrE,kBAAkB;IAClB;IACA,iBAAiB;IAClB,CAAC;GAEF,OAAO,KACL;IACE,gBAAgB,CAAC,CAAC,OAAO;IACzB,iBAAiB,CAAC,CAAC,OAAO;IAC1B,YAAY,CAAC,CAAC,OAAO;IACrB,WAAW,OAAO;IACnB,EACD,yCACD;GAED,OAAO;WACA,OAAO;GACd,OAAO,MAAM,EAAE,OAAO,EAAE,qCAAqC;GAC7D,MAAM;;;;;;;;;CAUV,MAAM,YAAY,aAA6C;EAC7D,OAAO,MAAM,gCAAgC;EAE7C,IAAI;GAEF,MAAM,eAAc,MADC,KAAK,WAAW,EACV,gBAAgB,CAAC;GAE5C,IAAI,CAAC,aACH,MAAM,IAAI,MAAM,sDAAsD;GAGxE,MAAM,WAAW,MAAM,MAAM,aAAa,EACxC,SAAS;IACP,eAAe,UAAU;IACzB,GAAI,KAAK,OAAO,mBAAmB,EACjC,6BAA6B,KAAK,OAAO,iBAC1C;IACD,GAAI,KAAK,OAAO,wBAAwB,EACtC,0BAA0B,KAAK,OAAO,sBACvC;IACF,EACF,CAAC;GAEF,IAAI,CAAC,SAAS,IAAI;IAChB,MAAM,YAAY,MAAM,SAAS,MAAM;IACvC,OAAO,MACL;KACE,QAAQ,SAAS;KACjB,OAAO;KACR,EACD,4BACD;IACD,MAAM,IAAI,MAAM,8BAA8B,SAAS,OAAO,KAAK,YAAY;;GAGjF,MAAM,WAAY,MAAM,SAAS,MAAM;GAEvC,OAAO,KACL;IACE,KAAK,SAAS;IACd,UAAU,CAAC,CAAC,SAAS;IACrB,UAAU,CAAC,CAAC,SAAS;IACrB,YAAY,CAAC,CAAC,SAAS,WAAW;IACnC,EACD,iCACD;GAED,OAAO;WACA,OAAO;GACd,OAAO,MAAM,EAAE,OAAO,EAAE,2BAA2B;GACnD,MAAM;;;;;;;;;CAUV,MAAM,cAAc,cAA6D;EAC/E,OAAO,MAAM,0BAA0B;EAEvC,IAAI;GACF,MAAM,SAAS,MAAM,KAAK,WAAW;GACrC,MAAM,SAAS,MAAM,OAAO,kBAAkB,QAAQ,aAAa;GAEnE,OAAO,KACL;IACE,gBAAgB,CAAC,CAAC,OAAO;IACzB,iBAAiB,CAAC,CAAC,OAAO;IAC1B,WAAW,OAAO;IACnB,EACD,gCACD;GAED,OAAO;WACA,OAAO;GACd,OAAO,MAAM,EAAE,OAAO,EAAE,2BAA2B;GACnD,MAAM"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/providers/vipps/types.ts","../../../src/providers/vipps/client.ts"],"sourcesContent":["/**\n * Vipps Login Provider Types\n *\n * Type definitions for Vipps Login (OIDC) integration.\n *\n * @see https://developer.vippsmobilepay.com/docs/APIs/login-api/\n */\n\n/**\n * Configuration for Vipps Login client\n */\nexport interface VippsLoginConfig {\n /** Vipps OIDC issuer URL (e.g., 'https://apitest.vipps.no/access-management-1.0/access') */\n issuer: string;\n /** OAuth Client ID from Vipps */\n clientId: string;\n /** OAuth Client Secret from Vipps */\n clientSecret: string;\n /** OAuth redirect URI (callback URL) */\n redirectUri: string;\n /** OpenID Connect scopes (default: 'openid name phoneNumber address email') */\n scope?: string;\n /** Vipps subscription key (Ocp-Apim-Subscription-Key) - required for userinfo endpoint */\n subscriptionKey?: string;\n /** Merchant serial number - required for userinfo endpoint */\n merchantSerialNumber?: string;\n}\n\n/**\n * Vipps address from userinfo endpoint\n */\nexport interface VippsAddress {\n /** Street address */\n street_address?: string;\n /** Postal code */\n postal_code?: string;\n /** Region/city */\n region?: string;\n /** Country code (e.g., 'NO') */\n country?: string;\n /** Formatted address string */\n formatted?: string;\n /** Address type (e.g., 'home', 'work', 'other') */\n address_type?: string;\n}\n\n/**\n * User information returned from Vipps Login userinfo endpoint\n *\n * @see https://developer.vippsmobilepay.com/docs/APIs/login-api/login-api-guide/#userinfo-endpoint\n */\nexport interface VippsUserInfo {\n /** Subject - unique Vipps user identifier (UUID) */\n sub: string;\n /** Email address */\n email?: string;\n /** Email verification status */\n email_verified?: boolean;\n /** Given name (first name) */\n given_name?: string;\n /** Family name (last name) */\n family_name?: string;\n /** Full name */\n name?: string;\n /** Phone number (with country code, e.g., '4712345678') */\n phone_number?: string;\n /** Phone number verification status */\n phone_number_verified?: boolean;\n /** Birth date (ISO 8601 format, e.g., '1990-01-15') */\n birthdate?: string;\n /** Physical addresses (array) */\n addresses?: VippsAddress[];\n /** Other claims (deprecated) */\n other_addresses?: VippsAddress[];\n}\n\n/**\n * Available Vipps Login scopes\n */\nexport const VippsLoginScopes = {\n /** Required: OpenID Connect base scope */\n OpenId: 'openid',\n /** User's name (given_name, family_name, name) */\n Name: 'name',\n /** Phone number */\n PhoneNumber: 'phoneNumber',\n /** Physical address */\n Address: 'address',\n /** Birth date */\n BirthDate: 'birthDate',\n /** Email address */\n Email: 'email',\n} as const;\n\n/**\n * Default scopes for Vipps Login\n */\nexport const defaultVippsLoginScope = [\n VippsLoginScopes.OpenId,\n VippsLoginScopes.Name,\n VippsLoginScopes.PhoneNumber,\n VippsLoginScopes.Address,\n VippsLoginScopes.Email,\n].join(' ');\n\n/**\n * Vipps API environments\n */\nexport const VippsEnvironments = {\n /** Test environment */\n Test: 'https://apitest.vipps.no',\n /** Production environment */\n Production: 'https://api.vipps.no',\n} as const;\n\n/**\n * Get Vipps OIDC issuer URL for environment\n */\nexport function getVippsIssuer(environment: string): string {\n // Note: Vipps metadata returns issuer with trailing slash\n return `${environment}/access-management-1.0/access/`;\n}\n","/**\n * Vipps Login Client\n *\n * Client for Vipps Login (OIDC) authentication flow.\n * Implements OAuth/OIDC operations with PKCE for Vipps.\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from '../../logger';\nimport {\n buildAuthorizationUrl,\n buildPKCEOptions,\n type OAuthConfig,\n type PKCEOptions,\n} from '../../oauth';\nimport {\n defaultVippsLoginScope,\n type VippsLoginConfig,\n type VippsUserInfo,\n} from './types';\n\nconst logger = createLogger({\n namespace: 'fides-auth:vipps',\n context: { module: 'VippsLoginClient' },\n});\n\n/**\n * Vipps Login Client\n *\n * Handles Vipps Login (OIDC) authentication flow using PKCE.\n */\nexport class VippsLoginClient {\n private readonly config: VippsLoginConfig;\n private readonly oauthConfig: OAuthConfig;\n private configPromise: Promise<openid.Configuration> | null = null;\n\n constructor(config: VippsLoginConfig) {\n this.config = {\n ...config,\n scope: config.scope ?? defaultVippsLoginScope,\n };\n\n this.oauthConfig = {\n issuer: config.issuer,\n clientId: config.clientId,\n clientSecret: config.clientSecret,\n redirect_uri: config.redirectUri,\n scope: this.config.scope!,\n };\n\n logger.info(\n {\n issuer: config.issuer,\n redirectUri: config.redirectUri,\n scope: this.config.scope,\n },\n 'VippsLoginClient initialized'\n );\n }\n\n /**\n * Get OpenID Configuration (cached)\n * This ensures we use the same Configuration instance across all operations\n */\n private async getConfig(): Promise<openid.Configuration> {\n if (!this.configPromise) {\n this.configPromise = openid.discovery(\n new URL(this.oauthConfig.issuer),\n this.oauthConfig.clientId,\n this.oauthConfig.clientSecret,\n openid.ClientSecretPost(this.oauthConfig.clientSecret)\n );\n }\n return this.configPromise;\n }\n\n /**\n * Build PKCE options for authorization flow\n *\n * @returns PKCE options including code_verifier, code_challenge, and state\n */\n async buildPKCEOptions(): Promise<PKCEOptions> {\n logger.debug('Building PKCE options');\n return buildPKCEOptions(this.oauthConfig);\n }\n\n /**\n * Build authorization URL for Vipps Login\n *\n * @param pkceOptions - PKCE options from buildPKCEOptions()\n * @returns Authorization URL to redirect user to\n */\n async buildAuthorizationUrl(pkceOptions: PKCEOptions): Promise<URL> {\n logger.debug('Building authorization URL');\n const config = await this.getConfig();\n return buildAuthorizationUrl(config, pkceOptions);\n }\n\n /**\n * Exchange authorization code for tokens\n *\n * @param callbackUrl - The full callback URL with code parameter from Vipps\n * @param codeVerifier - PKCE code verifier from initial request\n * @param expectedState - Expected state value for validation (optional)\n * @returns Token response with access_token, id_token, and refresh_token\n */\n async exchangeCodeForTokens(\n callbackUrl: string | URL,\n codeVerifier: string,\n expectedState?: string\n ): Promise<openid.TokenEndpointResponse> {\n logger.debug('Exchanging authorization code for tokens');\n\n try {\n const config = await this.getConfig();\n\n // Convert to URL if string\n const currentUrl = typeof callbackUrl === 'string' ? new URL(callbackUrl) : callbackUrl;\n\n const tokens = await openid.authorizationCodeGrant(config, currentUrl, {\n pkceCodeVerifier: codeVerifier,\n expectedState, // Pass the expected state for validation\n idTokenExpected: true,\n });\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n hasIdToken: !!tokens.id_token,\n expiresIn: tokens.expires_in,\n },\n 'Successfully exchanged code for tokens'\n );\n\n return tokens;\n } catch (error) {\n logger.error({ error }, 'Failed to exchange code for tokens');\n throw error;\n }\n }\n\n /**\n * Get user information from Vipps userinfo endpoint\n *\n * @param accessToken - Access token from token exchange\n * @returns Vipps user information\n */\n async getUserInfo(accessToken: string): Promise<VippsUserInfo> {\n logger.debug('Fetching user info from Vipps');\n\n try {\n const config = await this.getConfig();\n const userinfoUrl = config.serverMetadata().userinfo_endpoint;\n\n if (!userinfoUrl) {\n throw new Error('Userinfo endpoint not found in OpenID configuration');\n }\n\n const response = await fetch(userinfoUrl, {\n headers: {\n Authorization: `Bearer ${accessToken}`,\n ...(this.config.subscriptionKey && {\n 'Ocp-Apim-Subscription-Key': this.config.subscriptionKey,\n }),\n ...(this.config.merchantSerialNumber && {\n 'Merchant-Serial-Number': this.config.merchantSerialNumber,\n }),\n },\n });\n\n if (!response.ok) {\n const errorText = await response.text();\n logger.error(\n {\n status: response.status,\n error: errorText,\n },\n 'Failed to fetch user info'\n );\n throw new Error(`Failed to fetch user info: ${response.status} - ${errorText}`);\n }\n\n const userInfo = (await response.json()) as VippsUserInfo;\n\n logger.info(\n {\n sub: userInfo.sub,\n hasEmail: !!userInfo.email,\n hasPhone: !!userInfo.phone_number,\n hasAddress: !!userInfo.addresses?.length,\n },\n 'Successfully fetched user info'\n );\n\n return userInfo;\n } catch (error) {\n logger.error({ error }, 'Error fetching user info');\n throw error;\n }\n }\n\n /**\n * Refresh access token using refresh token\n *\n * @param refreshToken - Refresh token from initial token exchange\n * @returns New token response\n */\n async refreshTokens(refreshToken: string): Promise<openid.TokenEndpointResponse> {\n logger.debug('Refreshing access token');\n\n try {\n const config = await this.getConfig();\n const tokens = await openid.refreshTokenGrant(config, refreshToken);\n\n logger.info(\n {\n hasAccessToken: !!tokens.access_token,\n hasRefreshToken: !!tokens.refresh_token,\n expiresIn: tokens.expires_in,\n },\n 'Successfully refreshed tokens'\n );\n\n return tokens;\n } catch (error) {\n logger.error({ error }, 'Failed to refresh tokens');\n throw error;\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/rate-limit.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.js","names":[],"sources":["../src/rate-limit.ts"],"sourcesContent":["/**\n * Token bucket rate limiter for protecting against brute-force attacks.\n *\n * Each key (e.g. IP address, user ID) gets its own bucket. Tokens refill\n * over time at a fixed interval. When a bucket is empty, requests are denied.\n *\n * @typeParam _Key - The type of key used to identify rate-limited entities\n *\n * @example\n * ```typescript\n * const limiter = new TokenBucket<string>(10, 60); // 10 tokens, refill 1/min\n *\n * if (!limiter.consume(ipAddress, 1)) {\n * throw new Error('Rate limit exceeded');\n * }\n * ```\n */\nexport class TokenBucket<_Key> {\n public max: number;\n public refillIntervalSeconds: number;\n\n constructor(max: number, refillIntervalSeconds: number) {\n this.max = max;\n this.refillIntervalSeconds = refillIntervalSeconds;\n }\n\n private readonly storage = new Map<_Key, Bucket>();\n\n /** Check whether the bucket has enough tokens without consuming any. */\n public check(key: _Key, cost: number): boolean {\n const bucket = this.storage.get(key) ?? null;\n if (bucket === null) {\n return true;\n }\n const now = Date.now();\n const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));\n if (refill > 0) {\n return Math.min(bucket.count + refill, this.max) >= cost;\n }\n return bucket.count >= cost;\n }\n\n /** Consume tokens from the bucket. Returns `true` if allowed, `false` if rate-limited. */\n public consume(key: _Key, cost: number): boolean {\n let bucket = this.storage.get(key) ?? null;\n const now = Date.now();\n if (bucket === null) {\n bucket = {\n count: this.max - cost,\n refilledAt: now,\n };\n this.storage.set(key, bucket);\n return true;\n }\n const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));\n if (refill > 0) {\n bucket.count = Math.min(bucket.count + refill, this.max);\n bucket.refilledAt = now;\n }\n if (bucket.count < cost) {\n this.storage.set(key, bucket);\n return false;\n }\n bucket.count -= cost;\n this.storage.set(key, bucket);\n return true;\n }\n}\ninterface Bucket {\n count: number;\n refilledAt: number;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiBA,IAAa,cAAb,MAA+B;CAC7B;CACA;CAEA,YAAY,KAAa,uBAA+B;EACtD,KAAK,MAAM;EACX,KAAK,wBAAwB
|
|
1
|
+
{"version":3,"file":"rate-limit.js","names":[],"sources":["../src/rate-limit.ts"],"sourcesContent":["/**\n * Token bucket rate limiter for protecting against brute-force attacks.\n *\n * Each key (e.g. IP address, user ID) gets its own bucket. Tokens refill\n * over time at a fixed interval. When a bucket is empty, requests are denied.\n *\n * @typeParam _Key - The type of key used to identify rate-limited entities\n *\n * @example\n * ```typescript\n * const limiter = new TokenBucket<string>(10, 60); // 10 tokens, refill 1/min\n *\n * if (!limiter.consume(ipAddress, 1)) {\n * throw new Error('Rate limit exceeded');\n * }\n * ```\n */\nexport class TokenBucket<_Key> {\n public max: number;\n public refillIntervalSeconds: number;\n\n constructor(max: number, refillIntervalSeconds: number) {\n this.max = max;\n this.refillIntervalSeconds = refillIntervalSeconds;\n }\n\n private readonly storage = new Map<_Key, Bucket>();\n\n /** Check whether the bucket has enough tokens without consuming any. */\n public check(key: _Key, cost: number): boolean {\n const bucket = this.storage.get(key) ?? null;\n if (bucket === null) {\n return true;\n }\n const now = Date.now();\n const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));\n if (refill > 0) {\n return Math.min(bucket.count + refill, this.max) >= cost;\n }\n return bucket.count >= cost;\n }\n\n /** Consume tokens from the bucket. Returns `true` if allowed, `false` if rate-limited. */\n public consume(key: _Key, cost: number): boolean {\n let bucket = this.storage.get(key) ?? null;\n const now = Date.now();\n if (bucket === null) {\n bucket = {\n count: this.max - cost,\n refilledAt: now,\n };\n this.storage.set(key, bucket);\n return true;\n }\n const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));\n if (refill > 0) {\n bucket.count = Math.min(bucket.count + refill, this.max);\n bucket.refilledAt = now;\n }\n if (bucket.count < cost) {\n this.storage.set(key, bucket);\n return false;\n }\n bucket.count -= cost;\n this.storage.set(key, bucket);\n return true;\n }\n}\ninterface Bucket {\n count: number;\n refilledAt: number;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiBA,IAAa,cAAb,MAA+B;CAC7B;CACA;CAEA,YAAY,KAAa,uBAA+B;EACtD,KAAK,MAAM;EACX,KAAK,wBAAwB;CAC/B;CAEA,0BAA2B,IAAI,IAAkB;;CAGjD,MAAa,KAAW,MAAuB;EAC7C,MAAM,SAAS,KAAK,QAAQ,IAAI,GAAG,KAAK;EACxC,IAAI,WAAW,MACb,OAAO;EAGT,MAAM,SAAS,KAAK,OADR,KAAK,IACU,IAAM,OAAO,eAAe,KAAK,wBAAwB,IAAK;EACzF,IAAI,SAAS,GACX,OAAO,KAAK,IAAI,OAAO,QAAQ,QAAQ,KAAK,GAAG,KAAK;EAEtD,OAAO,OAAO,SAAS;CACzB;;CAGA,QAAe,KAAW,MAAuB;EAC/C,IAAI,SAAS,KAAK,QAAQ,IAAI,GAAG,KAAK;EACtC,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,WAAW,MAAM;GACnB,SAAS;IACP,OAAO,KAAK,MAAM;IAClB,YAAY;GACd;GACA,KAAK,QAAQ,IAAI,KAAK,MAAM;GAC5B,OAAO;EACT;EACA,MAAM,SAAS,KAAK,OAAO,MAAM,OAAO,eAAe,KAAK,wBAAwB,IAAK;EACzF,IAAI,SAAS,GAAG;GACd,OAAO,QAAQ,KAAK,IAAI,OAAO,QAAQ,QAAQ,KAAK,GAAG;GACvD,OAAO,aAAa;EACtB;EACA,IAAI,OAAO,QAAQ,MAAM;GACvB,KAAK,QAAQ,IAAI,KAAK,MAAM;GAC5B,OAAO;EACT;EACA,OAAO,SAAS;EAChB,KAAK,QAAQ,IAAI,KAAK,MAAM;EAC5B,OAAO;CACT;AACF"}
|
package/dist/session-refresh.js
CHANGED
|
@@ -25,7 +25,7 @@ var refreshSession = async (current_session, config, options = {}) => {
|
|
|
25
25
|
tokens: {
|
|
26
26
|
...current_session.tokens,
|
|
27
27
|
accessToken: newtokens.access_token,
|
|
28
|
-
accessTokenExpiresAt: newtokens.expires_in ? new Date(Date.now() + newtokens.expires_in * 1e3) : void 0,
|
|
28
|
+
accessTokenExpiresAt: newtokens.expires_in ? new Date(Date.now() + newtokens.expires_in * 1e3).toISOString() : void 0,
|
|
29
29
|
refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken
|
|
30
30
|
}
|
|
31
31
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { OAuthConfig, refreshAccessToken } from \"./oauth\";\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000)\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAKA,IAAM,SAAS,aAAa,EAAE,WAAW,
|
|
1
|
+
{"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { OAuthConfig, refreshAccessToken } from \"./oauth\";\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAKA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;;;AAWvE,IAAa,iBAAiB,OAC5B,iBACA,QACA,UAAgC,CAAC,MACL;CAC5B,OAAO,MAAM,0BAA0B;CAEvC,IAAI,CAAC,gBAAgB,QAAQ,cAAc;EACzC,OAAO,MAAM,uCAAuC;EACpD,MAAM,IAAI,MAAM,4BAA4B;CAC9C;CAEA,IAAI;EACF,MAAM,YAAY,MAAM,mBAAmB,QAAQ,gBAAgB,OAAO,YAAY;EAEtF,MAAM,iBAAiB;GACrB,GAAG;GACH,QAAQ;IACN,GAAG,gBAAgB;IACnB,aAAa,UAAU;IACvB,sBAAsB,UAAU,aAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,UAAU,aAAa,GAAI,EAAE,YAAY,IAC/D,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;GACnE;EACF;EAEA,OAAO,KAAK;GACV,mBAAmB,CAAC,CAAC,eAAe,OAAO;GAC3C,oBAAoB,CAAC,CAAC,UAAU;GAChC,WAAW,UAAU;EACvB,GAAG,gCAAgC;EAEnC,OAAO;CACT,SAAS,OAAO;EAEd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KAAK,2DAA2D;OAGvE,OAAO,MAAM,EAAE,OAAO,aAAa,GAAG,wBAAwB;EAGhE,MAAM;CACR;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-validation.js","names":[],"sources":["../src/session-validation.ts"],"sourcesContent":["// session-validation.ts\n\nimport { jwtDecrypt, decodeJwt } from 'jose';\n\nimport { createLogger } from './logger';\nimport { hexToUint8Array } from './utils';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-validation' });\n\n/** Result of validating an encrypted session JWT. */\nexport interface SessionValidationResult {\n /** The decoded session, if valid. */\n session?: Session;\n /** Validation status: VALID, INVALID (decryption/format error), or EXPIRED. */\n status: 'VALID' | 'INVALID' | 'EXPIRED';\n /** Seconds until the access token expires (negative means already expired). */\n accessTokenExpiresIn?: number;\n /** Human-readable reason when status is not VALID. */\n reason?: string;\n}\n\n/**\n * Validates an encrypted JWT and checks for expiration.\n *\n * @param encryptedJwt The encrypted JWT string to validate\n * @param secret The decryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns Session and SessionValidationResult indicating whether it's valid or not,\n * plus accessTokenExpiresIn if valid\n */\nexport async function validateSessionJwt(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<SessionValidationResult> {\n logger.debug('Starting session JWT validation');\n\n const secretBytes = typeof secret === 'string'\n ? hexToUint8Array(secret)\n : secret;\n\n let payload: unknown;\n try {\n ({ payload } = await jwtDecrypt(encryptedJwt, secretBytes));\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed decrypting JWT';\n logger.warn({ reason: errorMessage }, 'Session JWT decryption failed');\n return {\n status: 'INVALID',\n reason: `Decryption error: ${errorMessage}`\n };\n }\n\n // Check if payload has the shape of a Session\n if (!isSession(payload)) {\n logger.warn('JWT payload does not match Session structure');\n return {\n status: 'INVALID',\n reason: 'jwt does not seem to include a session'\n };\n }\n\n logger.debug('Session validated successfully');\n\n // Check access token expiry if available\n const expiresIn = computeAccessTokenExpiresIn(payload);\n\n if (expiresIn !== undefined && expiresIn <= 0) {\n logger.info({ accessTokenExpiresIn: expiresIn }, 'Session access token has expired');\n return {\n session: payload,\n status: 'EXPIRED',\n accessTokenExpiresIn: expiresIn,\n reason: 'Access token has expired',\n };\n }\n\n return {\n session: payload,\n status: 'VALID',\n accessTokenExpiresIn: expiresIn,\n };\n}\n\n/**\n * Computes the number of seconds until the access token expires.\n * Returns undefined if no expiry can be determined.\n */\nfunction computeAccessTokenExpiresIn(session: Session): number | undefined {\n const accessToken = session.tokens?.accessToken;\n if (!accessToken) {\n return undefined;\n }\n\n try {\n const { exp } = decodeJwt(accessToken);\n if (typeof exp !== 'number') {\n return undefined;\n }\n return Math.floor(exp - Date.now() / 1000);\n } catch {\n // Not a valid JWT or can't decode — ignore\n return undefined;\n }\n}\n\n\n/** Type guard that checks whether an unknown value conforms to the {@link Session} shape. */\nexport function isSession(object: unknown): object is Session {\n if (typeof object !== 'object' || object === null) {\n return false;\n }\n\n if (\n 'tokens' in object &&\n typeof (object as any).tokens !== 'object'\n ) {\n return false;\n }\n\n if (\n 'user' in object &&\n typeof (object as any).user !== 'object'\n ) {\n return false;\n }\n\n // Seems to be a real session\n return true;\n}\n"],"mappings":";;;;AAQA,IAAM,SAAS,aAAa,EAAE,WAAW,
|
|
1
|
+
{"version":3,"file":"session-validation.js","names":[],"sources":["../src/session-validation.ts"],"sourcesContent":["// session-validation.ts\n\nimport { jwtDecrypt, decodeJwt } from 'jose';\n\nimport { createLogger } from './logger';\nimport { hexToUint8Array } from './utils';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-validation' });\n\n/** Result of validating an encrypted session JWT. */\nexport interface SessionValidationResult {\n /** The decoded session, if valid. */\n session?: Session;\n /** Validation status: VALID, INVALID (decryption/format error), or EXPIRED. */\n status: 'VALID' | 'INVALID' | 'EXPIRED';\n /** Seconds until the access token expires (negative means already expired). */\n accessTokenExpiresIn?: number;\n /** Human-readable reason when status is not VALID. */\n reason?: string;\n}\n\n/**\n * Validates an encrypted JWT and checks for expiration.\n *\n * @param encryptedJwt The encrypted JWT string to validate\n * @param secret The decryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns Session and SessionValidationResult indicating whether it's valid or not,\n * plus accessTokenExpiresIn if valid\n */\nexport async function validateSessionJwt(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<SessionValidationResult> {\n logger.debug('Starting session JWT validation');\n\n const secretBytes = typeof secret === 'string'\n ? hexToUint8Array(secret)\n : secret;\n\n let payload: unknown;\n try {\n ({ payload } = await jwtDecrypt(encryptedJwt, secretBytes));\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed decrypting JWT';\n logger.warn({ reason: errorMessage }, 'Session JWT decryption failed');\n return {\n status: 'INVALID',\n reason: `Decryption error: ${errorMessage}`\n };\n }\n\n // Check if payload has the shape of a Session\n if (!isSession(payload)) {\n logger.warn('JWT payload does not match Session structure');\n return {\n status: 'INVALID',\n reason: 'jwt does not seem to include a session'\n };\n }\n\n logger.debug('Session validated successfully');\n\n // Check access token expiry if available\n const expiresIn = computeAccessTokenExpiresIn(payload);\n\n if (expiresIn !== undefined && expiresIn <= 0) {\n logger.info({ accessTokenExpiresIn: expiresIn }, 'Session access token has expired');\n return {\n session: payload,\n status: 'EXPIRED',\n accessTokenExpiresIn: expiresIn,\n reason: 'Access token has expired',\n };\n }\n\n return {\n session: payload,\n status: 'VALID',\n accessTokenExpiresIn: expiresIn,\n };\n}\n\n/**\n * Computes the number of seconds until the access token expires.\n * Returns undefined if no expiry can be determined.\n */\nfunction computeAccessTokenExpiresIn(session: Session): number | undefined {\n const accessToken = session.tokens?.accessToken;\n if (!accessToken) {\n return undefined;\n }\n\n try {\n const { exp } = decodeJwt(accessToken);\n if (typeof exp !== 'number') {\n return undefined;\n }\n return Math.floor(exp - Date.now() / 1000);\n } catch {\n // Not a valid JWT or can't decode — ignore\n return undefined;\n }\n}\n\n\n/** Type guard that checks whether an unknown value conforms to the {@link Session} shape. */\nexport function isSession(object: unknown): object is Session {\n if (typeof object !== 'object' || object === null) {\n return false;\n }\n\n if (\n 'tokens' in object &&\n typeof (object as any).tokens !== 'object'\n ) {\n return false;\n }\n\n if (\n 'user' in object &&\n typeof (object as any).user !== 'object'\n ) {\n return false;\n }\n\n // Seems to be a real session\n return true;\n}\n"],"mappings":";;;;AAQA,IAAM,SAAS,aAAa,EAAE,WAAW,gCAAgC,CAAC;;;;;;;;;AAsB1E,eAAsB,mBACpB,cACA,QACkC;CAClC,OAAO,MAAM,iCAAiC;CAE9C,MAAM,cAAc,OAAO,WAAW,WAClC,gBAAgB,MAAM,IACtB;CAEJ,IAAI;CACJ,IAAI;EACF,CAAC,CAAE,WAAY,MAAM,WAAW,cAAc,WAAW;CAC3D,SAAS,OAAO;EACd,MAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU;EAC9D,OAAO,KAAK,EAAE,QAAQ,aAAa,GAAG,+BAA+B;EACrE,OAAO;GACL,QAAQ;GACR,QAAQ,qBAAqB;EAC/B;CACF;CAGA,IAAI,CAAC,UAAU,OAAO,GAAG;EACvB,OAAO,KAAK,8CAA8C;EAC1D,OAAO;GACL,QAAQ;GACR,QAAQ;EACV;CACF;CAEA,OAAO,MAAM,gCAAgC;CAG7C,MAAM,YAAY,4BAA4B,OAAO;CAErD,IAAI,cAAc,KAAA,KAAa,aAAa,GAAG;EAC7C,OAAO,KAAK,EAAE,sBAAsB,UAAU,GAAG,kCAAkC;EACnF,OAAO;GACL,SAAS;GACT,QAAQ;GACR,sBAAsB;GACtB,QAAQ;EACV;CACF;CAEA,OAAO;EACL,SAAS;EACT,QAAQ;EACR,sBAAsB;CACxB;AACF;;;;;AAMA,SAAS,4BAA4B,SAAsC;CACzE,MAAM,cAAc,QAAQ,QAAQ;CACpC,IAAI,CAAC,aACH;CAGF,IAAI;EACF,MAAM,EAAE,QAAQ,UAAU,WAAW;EACrC,IAAI,OAAO,QAAQ,UACjB;EAEF,OAAO,KAAK,MAAM,MAAM,KAAK,IAAI,IAAI,GAAI;CAC3C,QAAQ;EAEN;CACF;AACF;;AAIA,SAAgB,UAAU,QAAoC;CAC5D,IAAI,OAAO,WAAW,YAAY,WAAW,MAC3C,OAAO;CAGT,IACE,YAAY,UACZ,OAAQ,OAAe,WAAW,UAElC,OAAO;CAGT,IACE,UAAU,UACV,OAAQ,OAAe,SAAS,UAEhC,OAAO;CAIT,OAAO;AACT"}
|
package/dist/silent-login.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"silent-login.js","names":[],"sources":["../src/silent-login.ts"],"sourcesContent":["/**\n * Silent login functionality for OAuth flows.\n *\n * Silent login (also called \"prompt=none\") attempts to authenticate the user\n * without any user interaction. This is useful for:\n * - Checking if a user has an active SSO session\n * - Refreshing authentication silently in the background\n * - Implementing \"keep me logged in\" features\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { OAuthConfig } from './oauth';\n\nconst logger = createLogger({ namespace: 'fides-auth:silent-login' });\n\nexport interface SilentLoginOptions {\n /** Optional hint about the user's identity (e.g., email or user ID) */\n loginHint?: string;\n /** Maximum age of authentication in seconds */\n maxAge?: number;\n /** Additional parameters to pass to the authorization endpoint */\n additionalParams?: Record<string, string>;\n}\n\nexport interface SilentLoginResult {\n /** Whether silent login was successful */\n success: boolean;\n /** The authorization URL to use (only if success is true) */\n authorizationUrl?: URL;\n /** Error message if silent login failed */\n error?: string;\n /** Detailed error description from OAuth provider */\n errorDescription?: string;\n}\n\n/**\n * Builds an authorization URL for silent authentication.\n *\n * This function creates an authorization URL with prompt=none,\n * which tells the OAuth provider to authenticate without user interaction.\n * If the user doesn't have an active session, the provider will return an error.\n *\n * @param config - OAuth configuration\n * @param options - Silent login options\n * @returns Authorization URL for silent authentication\n *\n * @example\n * ```ts\n * const silentAuthUrl = await buildSilentLoginUrl(oauthConfig, {\n * loginHint: 'user@example.com',\n * maxAge: 3600, // Require authentication within last hour\n * });\n *\n * // Use this URL in an iframe or hidden window\n * ```\n */\nexport async function buildSilentLoginUrl(\n config: OAuthConfig,\n options: SilentLoginOptions = {}\n): Promise<URL> {\n logger.debug({ issuer: config.issuer }, 'Building silent login URL');\n\n try {\n const server = await openid.discovery(\n new URL(config.issuer),\n config.clientId,\n config.clientSecret,\n openid.ClientSecretPost(config.clientSecret)\n );\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope,\n prompt: 'none', // Critical: tells provider to not show any UI\n response_type: 'code',\n ...options.additionalParams,\n };\n\n // Add optional parameters\n if (options.loginHint) {\n parameters.login_hint = options.loginHint;\n }\n\n if (options.maxAge !== undefined) {\n parameters.max_age = options.maxAge.toString();\n }\n\n const authUrl = openid.buildAuthorizationUrl(server, parameters);\n\n logger.info('Silent login URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error, issuer: config.issuer }, 'Failed to build silent login URL');\n throw error;\n }\n}\n\n/**\n * Checks if silent login is possible by examining the callback URL.\n *\n * After redirecting to the silent login URL, the OAuth provider will redirect back\n * with either a code (success) or an error (failure). This function parses the\n * callback URL to determine the result.\n *\n * @param callbackUrl - The URL the OAuth provider redirected to\n * @returns Result indicating success or failure with details\n *\n * @example\n * ```ts\n * // In your OAuth callback handler\n * const result = checkSilentLoginResult(new URL(request.url));\n *\n * if (result.success) {\n * // User has active SSO session, proceed with token exchange\n * const code = new URL(request.url).searchParams.get('code');\n * // ... exchange code for tokens\n * } else if (result.error === 'login_required') {\n * // User needs to log in interactively\n * redirect('/login');\n * } else {\n * // Other error\n * console.error(result.errorDescription);\n * }\n * ```\n */\nexport function checkSilentLoginResult(callbackUrl: URL): SilentLoginResult {\n const error = callbackUrl.searchParams.get('error');\n const errorDescription = callbackUrl.searchParams.get('error_description');\n const code = callbackUrl.searchParams.get('code');\n\n if (error) {\n logger.info({ error, errorDescription }, 'Silent login failed with error');\n\n return {\n success: false,\n error,\n errorDescription: errorDescription ?? undefined,\n };\n }\n\n if (code) {\n logger.info('Silent login successful');\n return {\n success: true,\n };\n }\n\n logger.warn('Silent login callback missing both code and error');\n return {\n success: false,\n error: 'invalid_callback',\n errorDescription: 'Callback URL missing both code and error parameters',\n };\n}\n\n/**\n * Common OAuth errors returned from silent login attempts.\n *\n * These can be used to handle specific error cases:\n * - login_required: User must log in interactively\n * - interaction_required: User interaction is needed (e.g., consent)\n * - consent_required: User must grant consent\n * - account_selection_required: User must select an account\n */\nexport const SilentLoginErrors = {\n LOGIN_REQUIRED: 'login_required',\n INTERACTION_REQUIRED: 'interaction_required',\n CONSENT_REQUIRED: 'consent_required',\n ACCOUNT_SELECTION_REQUIRED: 'account_selection_required',\n} as const;\n\nexport type SilentLoginError = typeof SilentLoginErrors[keyof typeof SilentLoginErrors];\n\n/**\n * Helper to check if an error requires interactive login.\n *\n * @param error - The error code from the OAuth provider\n * @returns True if the user must log in interactively\n *\n * @example\n * ```ts\n * const result = checkSilentLoginResult(callbackUrl);\n * if (!result.success && requiresInteractiveLogin(result.error)) {\n * // Redirect to full login page\n * redirect('/login');\n * }\n * ```\n */\nexport function requiresInteractiveLogin(error: string | undefined): boolean {\n if (!error) return false;\n\n return Object.values(SilentLoginErrors).includes(error as SilentLoginError);\n}\n"],"mappings":";;;;;;;;;;;;AAeA,IAAM,SAAS,aAAa,EAAE,WAAW,
|
|
1
|
+
{"version":3,"file":"silent-login.js","names":[],"sources":["../src/silent-login.ts"],"sourcesContent":["/**\n * Silent login functionality for OAuth flows.\n *\n * Silent login (also called \"prompt=none\") attempts to authenticate the user\n * without any user interaction. This is useful for:\n * - Checking if a user has an active SSO session\n * - Refreshing authentication silently in the background\n * - Implementing \"keep me logged in\" features\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { OAuthConfig } from './oauth';\n\nconst logger = createLogger({ namespace: 'fides-auth:silent-login' });\n\nexport interface SilentLoginOptions {\n /** Optional hint about the user's identity (e.g., email or user ID) */\n loginHint?: string;\n /** Maximum age of authentication in seconds */\n maxAge?: number;\n /** Additional parameters to pass to the authorization endpoint */\n additionalParams?: Record<string, string>;\n}\n\nexport interface SilentLoginResult {\n /** Whether silent login was successful */\n success: boolean;\n /** The authorization URL to use (only if success is true) */\n authorizationUrl?: URL;\n /** Error message if silent login failed */\n error?: string;\n /** Detailed error description from OAuth provider */\n errorDescription?: string;\n}\n\n/**\n * Builds an authorization URL for silent authentication.\n *\n * This function creates an authorization URL with prompt=none,\n * which tells the OAuth provider to authenticate without user interaction.\n * If the user doesn't have an active session, the provider will return an error.\n *\n * @param config - OAuth configuration\n * @param options - Silent login options\n * @returns Authorization URL for silent authentication\n *\n * @example\n * ```ts\n * const silentAuthUrl = await buildSilentLoginUrl(oauthConfig, {\n * loginHint: 'user@example.com',\n * maxAge: 3600, // Require authentication within last hour\n * });\n *\n * // Use this URL in an iframe or hidden window\n * ```\n */\nexport async function buildSilentLoginUrl(\n config: OAuthConfig,\n options: SilentLoginOptions = {}\n): Promise<URL> {\n logger.debug({ issuer: config.issuer }, 'Building silent login URL');\n\n try {\n const server = await openid.discovery(\n new URL(config.issuer),\n config.clientId,\n config.clientSecret,\n openid.ClientSecretPost(config.clientSecret)\n );\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope,\n prompt: 'none', // Critical: tells provider to not show any UI\n response_type: 'code',\n ...options.additionalParams,\n };\n\n // Add optional parameters\n if (options.loginHint) {\n parameters.login_hint = options.loginHint;\n }\n\n if (options.maxAge !== undefined) {\n parameters.max_age = options.maxAge.toString();\n }\n\n const authUrl = openid.buildAuthorizationUrl(server, parameters);\n\n logger.info('Silent login URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error, issuer: config.issuer }, 'Failed to build silent login URL');\n throw error;\n }\n}\n\n/**\n * Checks if silent login is possible by examining the callback URL.\n *\n * After redirecting to the silent login URL, the OAuth provider will redirect back\n * with either a code (success) or an error (failure). This function parses the\n * callback URL to determine the result.\n *\n * @param callbackUrl - The URL the OAuth provider redirected to\n * @returns Result indicating success or failure with details\n *\n * @example\n * ```ts\n * // In your OAuth callback handler\n * const result = checkSilentLoginResult(new URL(request.url));\n *\n * if (result.success) {\n * // User has active SSO session, proceed with token exchange\n * const code = new URL(request.url).searchParams.get('code');\n * // ... exchange code for tokens\n * } else if (result.error === 'login_required') {\n * // User needs to log in interactively\n * redirect('/login');\n * } else {\n * // Other error\n * console.error(result.errorDescription);\n * }\n * ```\n */\nexport function checkSilentLoginResult(callbackUrl: URL): SilentLoginResult {\n const error = callbackUrl.searchParams.get('error');\n const errorDescription = callbackUrl.searchParams.get('error_description');\n const code = callbackUrl.searchParams.get('code');\n\n if (error) {\n logger.info({ error, errorDescription }, 'Silent login failed with error');\n\n return {\n success: false,\n error,\n errorDescription: errorDescription ?? undefined,\n };\n }\n\n if (code) {\n logger.info('Silent login successful');\n return {\n success: true,\n };\n }\n\n logger.warn('Silent login callback missing both code and error');\n return {\n success: false,\n error: 'invalid_callback',\n errorDescription: 'Callback URL missing both code and error parameters',\n };\n}\n\n/**\n * Common OAuth errors returned from silent login attempts.\n *\n * These can be used to handle specific error cases:\n * - login_required: User must log in interactively\n * - interaction_required: User interaction is needed (e.g., consent)\n * - consent_required: User must grant consent\n * - account_selection_required: User must select an account\n */\nexport const SilentLoginErrors = {\n LOGIN_REQUIRED: 'login_required',\n INTERACTION_REQUIRED: 'interaction_required',\n CONSENT_REQUIRED: 'consent_required',\n ACCOUNT_SELECTION_REQUIRED: 'account_selection_required',\n} as const;\n\nexport type SilentLoginError = typeof SilentLoginErrors[keyof typeof SilentLoginErrors];\n\n/**\n * Helper to check if an error requires interactive login.\n *\n * @param error - The error code from the OAuth provider\n * @returns True if the user must log in interactively\n *\n * @example\n * ```ts\n * const result = checkSilentLoginResult(callbackUrl);\n * if (!result.success && requiresInteractiveLogin(result.error)) {\n * // Redirect to full login page\n * redirect('/login');\n * }\n * ```\n */\nexport function requiresInteractiveLogin(error: string | undefined): boolean {\n if (!error) return false;\n\n return Object.values(SilentLoginErrors).includes(error as SilentLoginError);\n}\n"],"mappings":";;;;;;;;;;;;AAeA,IAAM,SAAS,aAAa,EAAE,WAAW,0BAA0B,CAAC;;;;;;;;;;;;;;;;;;;;;;AA2CpE,eAAsB,oBACpB,QACA,UAA8B,CAAC,GACjB;CACd,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,GAAG,2BAA2B;CAEnE,IAAI;EACF,MAAM,SAAS,MAAM,OAAO,UAC1B,IAAI,IAAI,OAAO,MAAM,GACrB,OAAO,UACP,OAAO,cACP,OAAO,iBAAiB,OAAO,YAAY,CAC7C;EAEA,MAAM,aAAqC;GACzC,cAAc,OAAO;GACrB,OAAO,OAAO;GACd,QAAQ;GACR,eAAe;GACf,GAAG,QAAQ;EACb;EAGA,IAAI,QAAQ,WACV,WAAW,aAAa,QAAQ;EAGlC,IAAI,QAAQ,WAAW,KAAA,GACrB,WAAW,UAAU,QAAQ,OAAO,SAAS;EAG/C,MAAM,UAAU,OAAO,sBAAsB,QAAQ,UAAU;EAE/D,OAAO,KAAK,qCAAqC;EACjD,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MAAM;GAAE;GAAO,QAAQ,OAAO;EAAO,GAAG,kCAAkC;EACjF,MAAM;CACR;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,uBAAuB,aAAqC;CAC1E,MAAM,QAAQ,YAAY,aAAa,IAAI,OAAO;CAClD,MAAM,mBAAmB,YAAY,aAAa,IAAI,mBAAmB;CACzE,MAAM,OAAO,YAAY,aAAa,IAAI,MAAM;CAEhD,IAAI,OAAO;EACT,OAAO,KAAK;GAAE;GAAO;EAAiB,GAAG,gCAAgC;EAEzE,OAAO;GACL,SAAS;GACT;GACA,kBAAkB,oBAAoB,KAAA;EACxC;CACF;CAEA,IAAI,MAAM;EACR,OAAO,KAAK,yBAAyB;EACrC,OAAO,EACL,SAAS,KACX;CACF;CAEA,OAAO,KAAK,mDAAmD;CAC/D,OAAO;EACL,SAAS;EACT,OAAO;EACP,kBAAkB;CACpB;AACF;;;;;;;;;;AAWA,IAAa,oBAAoB;CAC/B,gBAAgB;CAChB,sBAAsB;CACtB,kBAAkB;CAClB,4BAA4B;AAC9B;;;;;;;;;;;;;;;;AAmBA,SAAgB,yBAAyB,OAAoC;CAC3E,IAAI,CAAC,OAAO,OAAO;CAEnB,OAAO,OAAO,OAAO,iBAAiB,EAAE,SAAS,KAAyB;AAC5E"}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/** OAuth/OIDC token set stored in a session. */
|
|
2
2
|
export interface Tokens {
|
|
3
3
|
accessToken?: string;
|
|
4
|
-
|
|
4
|
+
/** ISO 8601 string — the session is a JSON/JWT envelope, so dates live as strings on the wire. */
|
|
5
|
+
accessTokenExpiresAt?: string;
|
|
5
6
|
refreshToken?: string;
|
|
6
|
-
|
|
7
|
+
/** ISO 8601 string — see {@link accessTokenExpiresAt}. */
|
|
8
|
+
refreshTokenExpiresAt?: string;
|
|
7
9
|
}
|
|
8
10
|
/** Authenticated user session with tokens, user info, and optional custom data. */
|
|
9
11
|
export interface Session<TData = Record<string, unknown>> {
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oBAAoB,CAAC,EAAE,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,KAAK,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B"}
|
package/dist/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import * as jose from 'jose';\n\nimport { createLogger } from './logger';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:utils' });\n\n\n/**\n * Converts a hex string to a Uint8Array.\n *\n * @param hex - A hexadecimal string.\n * @returns A Uint8Array representing the bytes of the hex string.\n */\nexport function hexToUint8Array(hex: string): Uint8Array {\n if (hex.length % 2 !== 0) {\n throw new Error('Invalid hex string: odd length');\n }\n if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {\n throw new Error('Invalid hex string: contains non-hex characters');\n }\n const arr = new Uint8Array(hex.length / 2);\n for (let i = 0; i < arr.length; i++) {\n arr[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n return arr;\n}\n\n/**\n * Normalizes a secret to a Uint8Array.\n * Accepts either a hex-encoded string or an existing Uint8Array.\n */\nfunction normalizeSecret(secret: string | Uint8Array): Uint8Array {\n if (typeof secret === 'string') {\n return hexToUint8Array(secret);\n }\n return secret;\n}\n\n/**\n * Encrypts a payload into a JWT using the specified secret.\n *\n * @param payload - The payload to encrypt (can be any JSON-serializable value)\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns A promise that resolves to the encrypted JWT as a string.\n */\nexport async function createEncryptedJWT(\n payload: unknown,\n secret: string | Uint8Array,\n): Promise<string> {\n return new jose.EncryptJWT(payload as jose.JWTPayload)\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .encrypt(normalizeSecret(secret));\n}\n\n\n/**\n * Encrypts the given text using AES-GCM via the Web Crypto API.\n * Returns a string in the format: iv:authTag:ciphertext, all in hex.\n *\n * @param text - The plaintext to encrypt.\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the encrypted string.\n */\nexport async function encrypt(text: string, secret: string | Uint8Array): Promise<string> {\n const keyData = normalizeSecret(secret);\n\n // Import the key for AES-GCM encryption.\n const key = await crypto.subtle.importKey(\n 'raw',\n keyData as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['encrypt']\n );\n\n // Generate a 12-byte IV using the Web Crypto API.\n const iv = crypto.getRandomValues(new Uint8Array(12));\n\n // Encode the plaintext into a Uint8Array.\n const encoder = new TextEncoder();\n const data = encoder.encode(text);\n\n // Encrypt the data using AES-GCM.\n // Note: The Web Crypto API combines the ciphertext and authentication tag in the output.\n const encryptedBuffer = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv, tagLength: 128 },\n key,\n data\n );\n\n // Convert the encrypted data to a Uint8Array for separation.\n const encryptedArray = new Uint8Array(encryptedBuffer);\n const tagLength = 16; // AES-GCM uses a 128-bit (16-byte) tag.\n const ciphertext = encryptedArray.slice(0, encryptedArray.length - tagLength);\n const authTag = encryptedArray.slice(encryptedArray.length - tagLength);\n\n return [toHex(iv), toHex(authTag), toHex(ciphertext)].join(':');\n}\n\n/**\n * Decrypts data encrypted with AES-256-GCM (using the Web Crypto API).\n * The input is expected to be in the format: iv:authTag:ciphertext (all hex-encoded).\n *\n * @param data - The encrypted data string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the decrypted plaintext.\n */\nexport async function decrypt(data: string, secret: string | Uint8Array): Promise<string> {\n\n // Split the incoming data. We expect exactly three parts.\n const [ivHex, tagHex, ciphertextHex] = data.split(':');\n if (!ivHex || !tagHex || !ciphertextHex) {\n throw new Error('Invalid encrypted data format');\n }\n\n // Convert each hex-encoded part to a Uint8Array\n const iv = hexToUint8Array(ivHex);\n const tag = hexToUint8Array(tagHex);\n const ciphertext = hexToUint8Array(ciphertextHex);\n\n // Concatenate ciphertext and auth tag (Web Crypto expects them combined)\n const combined = new Uint8Array(ciphertext.length + tag.length);\n combined.set(ciphertext);\n combined.set(tag, ciphertext.length);\n\n // Convert the secret and import it as a CryptoKey for AES-GCM decryption.\n const keyData = normalizeSecret(secret);\n // Ensure keyData is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const keyDataArrayBuffer = new Uint8Array([...keyData]);\n\n // Ensure iv is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const ivArrayBuffer = new Uint8Array([...iv]);\n\n const key = await crypto.subtle.importKey(\n 'raw',\n keyDataArrayBuffer as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['decrypt']\n );\n\n // Decrypt the combined ciphertext+tag using the IV.\n const decryptedBuffer = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: ivArrayBuffer, tagLength: 128 },\n key,\n combined.buffer\n );\n\n // Decode the decrypted ArrayBuffer back to a string.\n const decoder = new TextDecoder();\n return decoder.decode(decryptedBuffer);\n}\n\n/**\n * Converts input data (string or Uint8Array) to a Uint8Array backed by a plain ArrayBuffer.\n * @param data - The input data.\n * @returns A Uint8Array representing the input data.\n */\nfunction toUint8Array(data: string | Uint8Array): Uint8Array {\n if (typeof data === 'string') {\n return new TextEncoder().encode(data);\n }\n return data;\n}\n\n/**\n * Hashes the input using SHA-256 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-256 hash as a hex string.\n */\nexport async function sha256(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-256', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Hashes the input using SHA-512 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-512 hash as a hex string.\n */\nexport async function sha512(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-512', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Retrieves the session secret from environment variables.\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {string} The session secret.\n */\nexport function getSessionSecret(): string {\n const secret = process.env.SESSION_SECRET;\n if (!secret) {\n throw new Error('SESSION_SECRET is not defined');\n }\n return secret;\n}\n\n/**\n * Retrieves the session secret from environment variables as Uint8Array .\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {Uint8Array} The session secret as Uint8Array.\n */\nexport function getSessionSecretUint8Array(): Uint8Array {\n const secret = getSessionSecret();\n const keyData = hexToUint8Array(secret);\n // Ensure it's backed by a regular ArrayBuffer (not SharedArrayBuffer)\n return new Uint8Array([...keyData]);\n}\n\n/**\n * Converts a Uint8Array to a lowercase hexadecimal string.\n */\nexport function toHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map(b => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generates a random token of the given byte length and returns it as a hex string.\n * Default token length is 20 bytes (160 bits).\n *\n * @param tokenLength - The number of random bytes to generate (default is 20).\n * @returns A hex string representing the token.\n */\nexport function generateToken(tokenLength: number = 32): string {\n const bytes = new Uint8Array(tokenLength);\n crypto.getRandomValues(bytes);\n return toHex(bytes);\n}\n\n\n/**\n * Determines if the access token of a session will expire in less than the given threshold.\n *\n * It decodes the access token (assumed to be a JWT) and reads the `exp` claim.\n * The expiration (`exp`) claim is the UNIX timestamp (in seconds) when the token expires.\n *\n * @param accessToken - The access token string.\n * @param seconds - The threshold (in seconds) for checking expiration (default is 10 seconds).\n * @returns True if the access token expires within the threshold; otherwise, false.\n */\nexport const accessTokenExpires = (\n accessToken: string,\n seconds: number = 10\n): boolean => {\n try {\n // Decode the JWT without verifying the signature.\n const payload = jose.decodeJwt(accessToken) as { exp?: number; };\n\n // If there is no exp claim, assume the token does not expire\n if (!payload.exp) {\n return false;\n }\n\n const nowInSeconds = Date.now() / 1000;\n const remainingSeconds = payload.exp - nowInSeconds;\n\n return remainingSeconds < seconds;\n } catch (error) {\n // If decoding fails, treat the token as invalid so no refresh is triggered.\n logger.error(\n { error },\n 'Error decoding access token'\n );\n return true;\n }\n};\n\n/**\n * Returns true if the session was granted the given scope.\n *\n * @param session - The session to check\n * @param scope - The scope string to look for (e.g. \"operations.read\")\n */\nexport function hasScope(session: Session, scope: string): boolean {\n return session.scopes?.includes(scope) ?? false;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import * as jose from 'jose';\n\nimport { createLogger } from './logger';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:utils' });\n\n\n/**\n * Converts a hex string to a Uint8Array.\n *\n * @param hex - A hexadecimal string.\n * @returns A Uint8Array representing the bytes of the hex string.\n */\nexport function hexToUint8Array(hex: string): Uint8Array {\n if (hex.length % 2 !== 0) {\n throw new Error('Invalid hex string: odd length');\n }\n if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {\n throw new Error('Invalid hex string: contains non-hex characters');\n }\n const arr = new Uint8Array(hex.length / 2);\n for (let i = 0; i < arr.length; i++) {\n arr[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n return arr;\n}\n\n/**\n * Normalizes a secret to a Uint8Array.\n * Accepts either a hex-encoded string or an existing Uint8Array.\n */\nfunction normalizeSecret(secret: string | Uint8Array): Uint8Array {\n if (typeof secret === 'string') {\n return hexToUint8Array(secret);\n }\n return secret;\n}\n\n/**\n * Encrypts a payload into a JWT using the specified secret.\n *\n * @param payload - The payload to encrypt (can be any JSON-serializable value)\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns A promise that resolves to the encrypted JWT as a string.\n */\nexport async function createEncryptedJWT(\n payload: unknown,\n secret: string | Uint8Array,\n): Promise<string> {\n return new jose.EncryptJWT(payload as jose.JWTPayload)\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .encrypt(normalizeSecret(secret));\n}\n\n\n/**\n * Encrypts the given text using AES-GCM via the Web Crypto API.\n * Returns a string in the format: iv:authTag:ciphertext, all in hex.\n *\n * @param text - The plaintext to encrypt.\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the encrypted string.\n */\nexport async function encrypt(text: string, secret: string | Uint8Array): Promise<string> {\n const keyData = normalizeSecret(secret);\n\n // Import the key for AES-GCM encryption.\n const key = await crypto.subtle.importKey(\n 'raw',\n keyData as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['encrypt']\n );\n\n // Generate a 12-byte IV using the Web Crypto API.\n const iv = crypto.getRandomValues(new Uint8Array(12));\n\n // Encode the plaintext into a Uint8Array.\n const encoder = new TextEncoder();\n const data = encoder.encode(text);\n\n // Encrypt the data using AES-GCM.\n // Note: The Web Crypto API combines the ciphertext and authentication tag in the output.\n const encryptedBuffer = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv, tagLength: 128 },\n key,\n data\n );\n\n // Convert the encrypted data to a Uint8Array for separation.\n const encryptedArray = new Uint8Array(encryptedBuffer);\n const tagLength = 16; // AES-GCM uses a 128-bit (16-byte) tag.\n const ciphertext = encryptedArray.slice(0, encryptedArray.length - tagLength);\n const authTag = encryptedArray.slice(encryptedArray.length - tagLength);\n\n return [toHex(iv), toHex(authTag), toHex(ciphertext)].join(':');\n}\n\n/**\n * Decrypts data encrypted with AES-256-GCM (using the Web Crypto API).\n * The input is expected to be in the format: iv:authTag:ciphertext (all hex-encoded).\n *\n * @param data - The encrypted data string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the decrypted plaintext.\n */\nexport async function decrypt(data: string, secret: string | Uint8Array): Promise<string> {\n\n // Split the incoming data. We expect exactly three parts.\n const [ivHex, tagHex, ciphertextHex] = data.split(':');\n if (!ivHex || !tagHex || !ciphertextHex) {\n throw new Error('Invalid encrypted data format');\n }\n\n // Convert each hex-encoded part to a Uint8Array\n const iv = hexToUint8Array(ivHex);\n const tag = hexToUint8Array(tagHex);\n const ciphertext = hexToUint8Array(ciphertextHex);\n\n // Concatenate ciphertext and auth tag (Web Crypto expects them combined)\n const combined = new Uint8Array(ciphertext.length + tag.length);\n combined.set(ciphertext);\n combined.set(tag, ciphertext.length);\n\n // Convert the secret and import it as a CryptoKey for AES-GCM decryption.\n const keyData = normalizeSecret(secret);\n // Ensure keyData is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const keyDataArrayBuffer = new Uint8Array([...keyData]);\n\n // Ensure iv is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const ivArrayBuffer = new Uint8Array([...iv]);\n\n const key = await crypto.subtle.importKey(\n 'raw',\n keyDataArrayBuffer as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['decrypt']\n );\n\n // Decrypt the combined ciphertext+tag using the IV.\n const decryptedBuffer = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: ivArrayBuffer, tagLength: 128 },\n key,\n combined.buffer\n );\n\n // Decode the decrypted ArrayBuffer back to a string.\n const decoder = new TextDecoder();\n return decoder.decode(decryptedBuffer);\n}\n\n/**\n * Converts input data (string or Uint8Array) to a Uint8Array backed by a plain ArrayBuffer.\n * @param data - The input data.\n * @returns A Uint8Array representing the input data.\n */\nfunction toUint8Array(data: string | Uint8Array): Uint8Array {\n if (typeof data === 'string') {\n return new TextEncoder().encode(data);\n }\n return data;\n}\n\n/**\n * Hashes the input using SHA-256 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-256 hash as a hex string.\n */\nexport async function sha256(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-256', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Hashes the input using SHA-512 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-512 hash as a hex string.\n */\nexport async function sha512(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-512', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Retrieves the session secret from environment variables.\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {string} The session secret.\n */\nexport function getSessionSecret(): string {\n const secret = process.env.SESSION_SECRET;\n if (!secret) {\n throw new Error('SESSION_SECRET is not defined');\n }\n return secret;\n}\n\n/**\n * Retrieves the session secret from environment variables as Uint8Array .\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {Uint8Array} The session secret as Uint8Array.\n */\nexport function getSessionSecretUint8Array(): Uint8Array {\n const secret = getSessionSecret();\n const keyData = hexToUint8Array(secret);\n // Ensure it's backed by a regular ArrayBuffer (not SharedArrayBuffer)\n return new Uint8Array([...keyData]);\n}\n\n/**\n * Converts a Uint8Array to a lowercase hexadecimal string.\n */\nexport function toHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map(b => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generates a random token of the given byte length and returns it as a hex string.\n * Default token length is 20 bytes (160 bits).\n *\n * @param tokenLength - The number of random bytes to generate (default is 20).\n * @returns A hex string representing the token.\n */\nexport function generateToken(tokenLength: number = 32): string {\n const bytes = new Uint8Array(tokenLength);\n crypto.getRandomValues(bytes);\n return toHex(bytes);\n}\n\n\n/**\n * Determines if the access token of a session will expire in less than the given threshold.\n *\n * It decodes the access token (assumed to be a JWT) and reads the `exp` claim.\n * The expiration (`exp`) claim is the UNIX timestamp (in seconds) when the token expires.\n *\n * @param accessToken - The access token string.\n * @param seconds - The threshold (in seconds) for checking expiration (default is 10 seconds).\n * @returns True if the access token expires within the threshold; otherwise, false.\n */\nexport const accessTokenExpires = (\n accessToken: string,\n seconds: number = 10\n): boolean => {\n try {\n // Decode the JWT without verifying the signature.\n const payload = jose.decodeJwt(accessToken) as { exp?: number; };\n\n // If there is no exp claim, assume the token does not expire\n if (!payload.exp) {\n return false;\n }\n\n const nowInSeconds = Date.now() / 1000;\n const remainingSeconds = payload.exp - nowInSeconds;\n\n return remainingSeconds < seconds;\n } catch (error) {\n // If decoding fails, treat the token as invalid so no refresh is triggered.\n logger.error(\n { error },\n 'Error decoding access token'\n );\n return true;\n }\n};\n\n/**\n * Returns true if the session was granted the given scope.\n *\n * @param session - The session to check\n * @param scope - The scope string to look for (e.g. \"operations.read\")\n */\nexport function hasScope(session: Session, scope: string): boolean {\n return session.scopes?.includes(scope) ?? false;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventuras/fides-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Framework-agnostic OAuth/OIDC authentication library with PKCE, session management, and pluggable logging",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"oauth",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"import": "./dist/index.js"
|
|
33
33
|
},
|
|
34
|
+
"./activity-tracker": {
|
|
35
|
+
"types": "./dist/activity-tracker.d.ts",
|
|
36
|
+
"import": "./dist/activity-tracker.js"
|
|
37
|
+
},
|
|
34
38
|
"./logger": {
|
|
35
39
|
"types": "./dist/logger.d.ts",
|
|
36
40
|
"import": "./dist/logger.js"
|
|
@@ -91,8 +95,8 @@
|
|
|
91
95
|
"@types/node": "^24.12.0",
|
|
92
96
|
"vite": "^8.0.8",
|
|
93
97
|
"vitest": "^4.1.4",
|
|
94
|
-
"@eventuras/
|
|
95
|
-
"@eventuras/
|
|
98
|
+
"@eventuras/typescript-config": "1.0.0",
|
|
99
|
+
"@eventuras/vite-config": "0.2.2"
|
|
96
100
|
},
|
|
97
101
|
"engines": {
|
|
98
102
|
"node": ">=18"
|