@mastra/auth-okta 0.0.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/index.js ADDED
@@ -0,0 +1,585 @@
1
+ import { resolvePermissionsFromMapping, matchesPermission } from '@mastra/core/auth/ee';
2
+ import pkg from '@okta/okta-sdk-nodejs';
3
+ import { LRUCache } from 'lru-cache';
4
+ import { MastraAuthProvider } from '@mastra/core/server';
5
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
6
+
7
+ // src/rbac-provider.ts
8
+ var { Client } = pkg;
9
+ var DEFAULT_CACHE_TTL_MS = 60 * 1e3;
10
+ var DEFAULT_CACHE_MAX_SIZE = 1e3;
11
+ var MastraRBACOkta = class {
12
+ oktaClient;
13
+ options;
14
+ /**
15
+ * Single cache for roles (the expensive Okta API call).
16
+ * Permissions are derived from roles on-the-fly (cheap, synchronous).
17
+ * Storing promises handles concurrent request deduplication.
18
+ */
19
+ rolesCache;
20
+ /**
21
+ * Expose roleMapping for middleware access.
22
+ * This allows the authorization middleware to resolve permissions
23
+ * without needing to call the async methods.
24
+ */
25
+ get roleMapping() {
26
+ return this.options.roleMapping;
27
+ }
28
+ /**
29
+ * Create a new Okta RBAC provider.
30
+ *
31
+ * @param options - RBAC configuration options
32
+ */
33
+ constructor(options) {
34
+ const domain = options.domain ?? process.env.OKTA_DOMAIN;
35
+ const apiToken = options.apiToken ?? process.env.OKTA_API_TOKEN;
36
+ if (!domain) {
37
+ throw new Error(
38
+ "Okta domain is required. Provide it in the options or set OKTA_DOMAIN environment variable."
39
+ );
40
+ }
41
+ if (!apiToken) {
42
+ throw new Error(
43
+ "Okta API token is required for RBAC. Provide it in the options or set OKTA_API_TOKEN environment variable."
44
+ );
45
+ }
46
+ this.oktaClient = new Client({
47
+ orgUrl: `https://${domain}`,
48
+ token: apiToken
49
+ });
50
+ this.options = options;
51
+ this.rolesCache = new LRUCache({
52
+ max: options.cache?.maxSize ?? DEFAULT_CACHE_MAX_SIZE,
53
+ ttl: options.cache?.ttlMs ?? DEFAULT_CACHE_TTL_MS
54
+ });
55
+ }
56
+ /**
57
+ * Get all roles (groups) for a user from Okta.
58
+ *
59
+ * If the user object already has groups attached, uses those.
60
+ * Otherwise, fetches groups from Okta API and caches the result.
61
+ *
62
+ * @param user - User to get roles for
63
+ * @returns Array of group names
64
+ */
65
+ async getRoles(user) {
66
+ if (user.groups && user.groups.length > 0) {
67
+ return user.groups;
68
+ }
69
+ const userId = this.resolveUserId(user);
70
+ if (!userId) {
71
+ return [];
72
+ }
73
+ const cached = this.rolesCache.get(userId);
74
+ if (cached) {
75
+ return cached;
76
+ }
77
+ const groupsPromise = this.fetchGroupsFromOkta(userId).catch((err) => {
78
+ console.error(`[MastraRBACOkta] Failed to fetch groups for user ${userId}:`, err);
79
+ this.rolesCache.delete(userId);
80
+ return [];
81
+ });
82
+ this.rolesCache.set(userId, groupsPromise);
83
+ return groupsPromise;
84
+ }
85
+ /**
86
+ * Resolve the Okta user ID from the user object.
87
+ * Uses custom getUserId function if provided, otherwise falls back to oktaId or id.
88
+ */
89
+ resolveUserId(user) {
90
+ if (this.options.getUserId) {
91
+ return this.options.getUserId(user);
92
+ }
93
+ return user.oktaId ?? user.id;
94
+ }
95
+ /**
96
+ * Fetch groups from Okta API.
97
+ * Errors propagate to the caller so the cache eviction in getRoles() works.
98
+ */
99
+ async fetchGroupsFromOkta(userId) {
100
+ const groups = await this.oktaClient.userApi.listUserGroups({ userId });
101
+ const groupNames = [];
102
+ for await (const group of groups) {
103
+ if (group && group.profile?.name) {
104
+ groupNames.push(group.profile.name);
105
+ }
106
+ }
107
+ return groupNames;
108
+ }
109
+ /**
110
+ * Check if a user has a specific role (group).
111
+ *
112
+ * @param user - User to check
113
+ * @param role - Group name to check for
114
+ * @returns True if user has the group
115
+ */
116
+ async hasRole(user, role) {
117
+ const roles = await this.getRoles(user);
118
+ return roles.includes(role);
119
+ }
120
+ /**
121
+ * Get all permissions for a user by mapping their Okta groups.
122
+ *
123
+ * @param user - User to get permissions for
124
+ * @returns Array of permission strings
125
+ */
126
+ async getPermissions(user) {
127
+ const roles = await this.getRoles(user);
128
+ return resolvePermissionsFromMapping(roles, this.options.roleMapping);
129
+ }
130
+ /**
131
+ * Check if a user has a specific permission.
132
+ *
133
+ * @param user - User to check
134
+ * @param permission - Permission to check for (supports wildcards)
135
+ * @returns True if user has the permission
136
+ */
137
+ async hasPermission(user, permission) {
138
+ const permissions = await this.getPermissions(user);
139
+ return permissions.some((granted) => matchesPermission(granted, permission));
140
+ }
141
+ /**
142
+ * Check if a user has ALL of the specified permissions.
143
+ *
144
+ * @param user - User to check
145
+ * @param permissions - Permissions to check for
146
+ * @returns True if user has all permissions
147
+ */
148
+ async hasAllPermissions(user, permissions) {
149
+ const userPermissions = await this.getPermissions(user);
150
+ return permissions.every((required) => userPermissions.some((granted) => matchesPermission(granted, required)));
151
+ }
152
+ /**
153
+ * Check if a user has ANY of the specified permissions.
154
+ *
155
+ * @param user - User to check
156
+ * @param permissions - Permissions to check for
157
+ * @returns True if user has at least one permission
158
+ */
159
+ async hasAnyPermission(user, permissions) {
160
+ const userPermissions = await this.getPermissions(user);
161
+ return permissions.some((required) => userPermissions.some((granted) => matchesPermission(granted, required)));
162
+ }
163
+ };
164
+
165
+ // src/types.ts
166
+ function mapOktaClaimsToUser(payload) {
167
+ return {
168
+ id: payload.sub || payload.uid || "",
169
+ oktaId: payload.sub || payload.uid || "",
170
+ email: payload.email,
171
+ name: payload.name || [payload.given_name, payload.family_name].filter(Boolean).join(" ") || payload.email || void 0,
172
+ avatarUrl: payload.picture,
173
+ groups: payload.groups,
174
+ metadata: {
175
+ oktaId: payload.sub,
176
+ emailVerified: payload.email_verified,
177
+ updatedAt: payload.updated_at
178
+ }
179
+ };
180
+ }
181
+
182
+ // src/auth-provider.ts
183
+ var DEFAULT_COOKIE_NAME = "okta_session";
184
+ var DEFAULT_COOKIE_MAX_AGE = 86400;
185
+ var DEFAULT_SCOPES = ["openid", "profile", "email", "groups"];
186
+ var SALT_LENGTH = 16;
187
+ var IV_LENGTH = 12;
188
+ async function deriveKey(password, salt, usage) {
189
+ const encoder = new TextEncoder();
190
+ const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, [
191
+ "deriveBits",
192
+ "deriveKey"
193
+ ]);
194
+ return crypto.subtle.deriveKey(
195
+ { name: "PBKDF2", salt, iterations: 1e5, hash: "SHA-256" },
196
+ keyMaterial,
197
+ { name: "AES-GCM", length: 256 },
198
+ false,
199
+ [usage]
200
+ );
201
+ }
202
+ async function encryptSession(data, password) {
203
+ const encoder = new TextEncoder();
204
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
205
+ const key = await deriveKey(password, salt, "encrypt");
206
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
207
+ const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(JSON.stringify(data)));
208
+ const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(encrypted).length);
209
+ combined.set(salt);
210
+ combined.set(iv, salt.length);
211
+ combined.set(new Uint8Array(encrypted), salt.length + iv.length);
212
+ return btoa(String.fromCharCode(...combined));
213
+ }
214
+ async function decryptSession(encrypted, password) {
215
+ const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
216
+ const salt = combined.slice(0, SALT_LENGTH);
217
+ const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
218
+ const data = combined.slice(SALT_LENGTH + IV_LENGTH);
219
+ const key = await deriveKey(password, salt, "decrypt");
220
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data);
221
+ return JSON.parse(new TextDecoder().decode(decrypted));
222
+ }
223
+ var stateStore = /* @__PURE__ */ new Map();
224
+ var MastraAuthOkta = class extends MastraAuthProvider {
225
+ domain;
226
+ clientId;
227
+ clientSecret;
228
+ issuer;
229
+ redirectUri;
230
+ scopes;
231
+ cookieName;
232
+ cookieMaxAge;
233
+ cookiePassword;
234
+ secureCookies;
235
+ apiToken;
236
+ jwks;
237
+ constructor(options) {
238
+ super({ name: options?.name ?? "okta" });
239
+ const domain = options?.domain ?? process.env.OKTA_DOMAIN;
240
+ const clientId = options?.clientId ?? process.env.OKTA_CLIENT_ID;
241
+ const clientSecret = options?.clientSecret ?? process.env.OKTA_CLIENT_SECRET;
242
+ const issuer = options?.issuer ?? process.env.OKTA_ISSUER;
243
+ const redirectUri = options?.redirectUri ?? process.env.OKTA_REDIRECT_URI;
244
+ const cookiePassword = options?.session?.cookiePassword ?? process.env.OKTA_COOKIE_PASSWORD ?? crypto.randomUUID() + crypto.randomUUID();
245
+ if (!domain) {
246
+ throw new Error("Okta domain is required. Provide it in the options or set OKTA_DOMAIN environment variable.");
247
+ }
248
+ if (!clientId) {
249
+ throw new Error(
250
+ "Okta client ID is required. Provide it in the options or set OKTA_CLIENT_ID environment variable."
251
+ );
252
+ }
253
+ if (!clientSecret) {
254
+ throw new Error(
255
+ "Okta client secret is required for SSO. Provide it in the options or set OKTA_CLIENT_SECRET environment variable."
256
+ );
257
+ }
258
+ if (!redirectUri) {
259
+ throw new Error(
260
+ "Okta redirect URI is required for SSO. Provide it in the options or set OKTA_REDIRECT_URI environment variable."
261
+ );
262
+ }
263
+ if (cookiePassword.length < 32) {
264
+ throw new Error("Cookie password must be at least 32 characters. Set OKTA_COOKIE_PASSWORD environment variable.");
265
+ }
266
+ this.domain = domain;
267
+ this.clientId = clientId;
268
+ this.clientSecret = clientSecret;
269
+ this.issuer = issuer ?? `https://${domain}/oauth2/default`;
270
+ this.redirectUri = redirectUri;
271
+ this.scopes = options?.scopes ?? DEFAULT_SCOPES;
272
+ this.cookieName = options?.session?.cookieName ?? DEFAULT_COOKIE_NAME;
273
+ this.cookieMaxAge = options?.session?.cookieMaxAge ?? DEFAULT_COOKIE_MAX_AGE;
274
+ this.cookiePassword = cookiePassword;
275
+ this.secureCookies = options?.session?.secureCookies ?? process.env.NODE_ENV === "production";
276
+ this.apiToken = options?.apiToken ?? process.env.OKTA_API_TOKEN;
277
+ this.jwks = createRemoteJWKSet(new URL(`${this.issuer}/v1/keys`));
278
+ if (!options?.session?.cookiePassword && !process.env.OKTA_COOKIE_PASSWORD) {
279
+ console.warn(
280
+ "[MastraAuthOkta] No cookie password set \u2014 using auto-generated value. Sessions will not survive restarts and will break in multi-instance deployments. Set OKTA_COOKIE_PASSWORD for production use."
281
+ );
282
+ }
283
+ if (process.env.NODE_ENV === "production") {
284
+ console.warn(
285
+ "[MastraAuthOkta] Using in-memory OAuth state store. This will not work in serverless or multi-instance deployments. Consider implementing a custom state store for production."
286
+ );
287
+ }
288
+ this.registerOptions(options);
289
+ }
290
+ // ============================================================================
291
+ // MastraAuthProvider Implementation
292
+ // ============================================================================
293
+ /**
294
+ * Authenticate a token from the request.
295
+ * First tries to read from session cookie, then falls back to Authorization header.
296
+ */
297
+ async authenticateToken(token, request) {
298
+ const sessionUser = await this.getUserFromSession(request);
299
+ if (sessionUser) {
300
+ return sessionUser;
301
+ }
302
+ if (!token || typeof token !== "string") {
303
+ return null;
304
+ }
305
+ try {
306
+ const { payload } = await jwtVerify(token, this.jwks, {
307
+ issuer: this.issuer,
308
+ audience: this.clientId
309
+ });
310
+ return mapOktaClaimsToUser(payload);
311
+ } catch (err) {
312
+ console.error("Okta token verification failed:", err);
313
+ return null;
314
+ }
315
+ }
316
+ /**
317
+ * Authorize a user.
318
+ */
319
+ authorizeUser(user, _request) {
320
+ if (!user || !user.oktaId) return false;
321
+ return true;
322
+ }
323
+ // ============================================================================
324
+ // IUserProvider Implementation
325
+ // ============================================================================
326
+ /**
327
+ * Get the current user from the request session.
328
+ */
329
+ async getCurrentUser(request) {
330
+ return this.getUserFromSession(request);
331
+ }
332
+ /**
333
+ * Get a user by ID via the Okta Users API.
334
+ * Requires an API token (set OKTA_API_TOKEN or pass apiToken in options).
335
+ * Returns null if no API token is configured or user is not found.
336
+ */
337
+ async getUser(userId) {
338
+ if (!this.apiToken) {
339
+ return null;
340
+ }
341
+ try {
342
+ const response = await fetch(`https://${this.domain}/api/v1/users/${userId}`, {
343
+ headers: {
344
+ Authorization: `SSWS ${this.apiToken}`,
345
+ Accept: "application/json"
346
+ }
347
+ });
348
+ if (!response.ok) {
349
+ return null;
350
+ }
351
+ const oktaProfile = await response.json();
352
+ return {
353
+ id: oktaProfile.id,
354
+ oktaId: oktaProfile.id,
355
+ email: oktaProfile.profile.email,
356
+ name: [oktaProfile.profile.firstName, oktaProfile.profile.lastName].filter(Boolean).join(" ") || void 0
357
+ };
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+ /**
363
+ * Get user from session cookie.
364
+ */
365
+ async getUserFromSession(request) {
366
+ try {
367
+ const cookieHeader = "header" in request ? request.header("cookie") : request.headers.get("cookie");
368
+ if (!cookieHeader) return null;
369
+ const cookies = cookieHeader.split(";").map((c) => c.trim());
370
+ const sessionCookie = cookies.find((c) => c.startsWith(`${this.cookieName}=`));
371
+ if (!sessionCookie) return null;
372
+ const sessionValue = sessionCookie.split("=")[1];
373
+ if (!sessionValue) return null;
374
+ const session = await decryptSession(decodeURIComponent(sessionValue), this.cookiePassword);
375
+ if (session.expiresAt && session.expiresAt < Date.now()) {
376
+ return null;
377
+ }
378
+ return session.user;
379
+ } catch {
380
+ return null;
381
+ }
382
+ }
383
+ /**
384
+ * Extract the raw ID token from the encrypted session cookie.
385
+ * Used to provide id_token_hint for Okta logout.
386
+ */
387
+ async getIdTokenFromSession(request) {
388
+ try {
389
+ const cookieHeader = "header" in request ? request.header("cookie") : request.headers.get("cookie");
390
+ if (!cookieHeader) return null;
391
+ const cookies = cookieHeader.split(";").map((c) => c.trim());
392
+ const sessionCookie = cookies.find((c) => c.startsWith(`${this.cookieName}=`));
393
+ if (!sessionCookie) return null;
394
+ const sessionValue = sessionCookie.split("=")[1];
395
+ if (!sessionValue) return null;
396
+ const session = await decryptSession(decodeURIComponent(sessionValue), this.cookiePassword);
397
+ return session.idToken ?? null;
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ // ============================================================================
403
+ // ISSOProvider Implementation
404
+ // ============================================================================
405
+ /**
406
+ * Get the URL to redirect users to for Okta login.
407
+ * Uses client_secret authentication (no PKCE) since this is a confidential client.
408
+ */
409
+ getLoginUrl(redirectUri, state) {
410
+ const stateId = state.includes("|") ? state.split("|")[0] : state;
411
+ const actualRedirectUri = redirectUri ?? this.redirectUri;
412
+ stateStore.set(stateId, {
413
+ expiresAt: Date.now() + 10 * 60 * 1e3,
414
+ redirectUri: actualRedirectUri
415
+ });
416
+ for (const [key, value] of stateStore.entries()) {
417
+ if (value.expiresAt < Date.now()) {
418
+ stateStore.delete(key);
419
+ }
420
+ }
421
+ const params = new URLSearchParams({
422
+ client_id: this.clientId,
423
+ response_type: "code",
424
+ scope: this.scopes.join(" "),
425
+ redirect_uri: actualRedirectUri,
426
+ state
427
+ });
428
+ return `${this.issuer}/v1/authorize?${params.toString()}`;
429
+ }
430
+ /**
431
+ * Handle the OAuth callback from Okta.
432
+ * Note: The server passes only the stateId (UUID part), not the full state.
433
+ */
434
+ async handleCallback(code, stateId) {
435
+ const stored = stateStore.get(stateId);
436
+ if (!stored) {
437
+ throw new Error("Invalid or expired state parameter");
438
+ }
439
+ stateStore.delete(stateId);
440
+ if (stored.expiresAt < Date.now()) {
441
+ throw new Error("State parameter has expired");
442
+ }
443
+ const tokenResponse = await fetch(`${this.issuer}/v1/token`, {
444
+ method: "POST",
445
+ headers: {
446
+ "Content-Type": "application/x-www-form-urlencoded",
447
+ Authorization: `Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}`
448
+ },
449
+ body: new URLSearchParams({
450
+ grant_type: "authorization_code",
451
+ code,
452
+ redirect_uri: stored.redirectUri
453
+ })
454
+ });
455
+ if (!tokenResponse.ok) {
456
+ const error = await tokenResponse.text();
457
+ throw new Error(`Token exchange failed: ${error}`);
458
+ }
459
+ const tokens = await tokenResponse.json();
460
+ const { payload: idTokenPayload } = await jwtVerify(tokens.id_token, this.jwks, {
461
+ issuer: this.issuer,
462
+ audience: this.clientId
463
+ });
464
+ const user = mapOktaClaimsToUser(idTokenPayload);
465
+ const sessionData = {
466
+ user,
467
+ idToken: tokens.id_token,
468
+ expiresAt: Date.now() + tokens.expires_in * 1e3
469
+ };
470
+ const encryptedSession = await encryptSession(sessionData, this.cookiePassword);
471
+ const cookieValue = `${this.cookieName}=${encodeURIComponent(encryptedSession)}; ${this.cookieFlags(this.cookieMaxAge)}`;
472
+ return {
473
+ user,
474
+ tokens: {
475
+ accessToken: tokens.access_token,
476
+ refreshToken: tokens.refresh_token,
477
+ idToken: tokens.id_token,
478
+ expiresAt: new Date(Date.now() + tokens.expires_in * 1e3)
479
+ },
480
+ cookies: [cookieValue]
481
+ };
482
+ }
483
+ /**
484
+ * Get the URL to redirect users to for logout.
485
+ * Includes id_token_hint from session when available (required by Okta).
486
+ */
487
+ async getLogoutUrl(redirectUri, request) {
488
+ const params = new URLSearchParams({
489
+ post_logout_redirect_uri: redirectUri,
490
+ client_id: this.clientId
491
+ });
492
+ if (request) {
493
+ const idToken = await this.getIdTokenFromSession(request);
494
+ if (idToken) {
495
+ params.set("id_token_hint", idToken);
496
+ }
497
+ }
498
+ return `${this.issuer}/v1/logout?${params.toString()}`;
499
+ }
500
+ /**
501
+ * Get cookies to set during login.
502
+ */
503
+ getLoginCookies(_state) {
504
+ return [];
505
+ }
506
+ /**
507
+ * Get the configuration for rendering the login button.
508
+ */
509
+ getLoginButtonConfig() {
510
+ return {
511
+ provider: "okta",
512
+ text: "Sign in with Okta"
513
+ };
514
+ }
515
+ // ============================================================================
516
+ // ISessionProvider Implementation
517
+ // ============================================================================
518
+ async createSession(userId, metadata) {
519
+ const now = /* @__PURE__ */ new Date();
520
+ return {
521
+ id: crypto.randomUUID(),
522
+ userId,
523
+ createdAt: now,
524
+ expiresAt: new Date(now.getTime() + this.cookieMaxAge * 1e3),
525
+ metadata
526
+ };
527
+ }
528
+ async validateSession(_sessionId) {
529
+ return null;
530
+ }
531
+ async destroySession(_sessionId) {
532
+ }
533
+ async refreshSession(_sessionId) {
534
+ return null;
535
+ }
536
+ getSessionIdFromRequest(_request) {
537
+ return null;
538
+ }
539
+ getSessionHeaders(_session) {
540
+ return {};
541
+ }
542
+ getClearSessionHeaders() {
543
+ return {
544
+ "Set-Cookie": `${this.cookieName}=; ${this.cookieFlags(0)}`
545
+ };
546
+ }
547
+ /**
548
+ * Build consistent cookie attribute string for set/clear operations.
549
+ */
550
+ cookieFlags(maxAge) {
551
+ const flags = `Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
552
+ return this.secureCookies ? `${flags}; Secure` : flags;
553
+ }
554
+ // ============================================================================
555
+ // Helper Methods
556
+ // ============================================================================
557
+ /**
558
+ * Get the Okta domain.
559
+ */
560
+ getDomain() {
561
+ return this.domain;
562
+ }
563
+ /**
564
+ * Get the configured client ID.
565
+ */
566
+ getClientId() {
567
+ return this.clientId;
568
+ }
569
+ /**
570
+ * Get the configured redirect URI.
571
+ */
572
+ getRedirectUri() {
573
+ return this.redirectUri;
574
+ }
575
+ /**
576
+ * Get the issuer URL.
577
+ */
578
+ getIssuer() {
579
+ return this.issuer;
580
+ }
581
+ };
582
+
583
+ export { MastraAuthOkta, MastraRBACOkta, mapOktaClaimsToUser };
584
+ //# sourceMappingURL=index.js.map
585
+ //# sourceMappingURL=index.js.map