@oauth42/next 0.4.7 → 0.5.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.
@@ -116,12 +116,72 @@ function withOAuth42ServerSideProps(getServerSideProps, authOptions) {
116
116
  };
117
117
  }
118
118
 
119
+ // src/server/backend-session.ts
120
+ function isBackendSessionMode(mode) {
121
+ return mode === "backend";
122
+ }
123
+ function sessionEndpoint(issuer) {
124
+ return `${issuer.replace(/\/$/, "")}/api/sdk/sessions`;
125
+ }
126
+ async function createBackendSession(params) {
127
+ const response = await fetch(sessionEndpoint(params.issuer), {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json" },
130
+ body: JSON.stringify({
131
+ access_token: params.accessToken,
132
+ refresh_token: params.refreshToken,
133
+ expires_at: params.expiresAt ?? Math.floor(Date.now() / 1e3) + 3600,
134
+ id_token: params.idToken,
135
+ client_id: params.clientId,
136
+ client_secret: params.clientSecret,
137
+ issuer: params.issuer,
138
+ user: params.user
139
+ })
140
+ });
141
+ if (!response.ok) {
142
+ throw new Error(`OAuth42 backend session create failed: ${response.status}`);
143
+ }
144
+ return response.json();
145
+ }
146
+ async function resolveBackendSession(params) {
147
+ const response = await fetch(`${sessionEndpoint(params.issuer)}/resolve`, {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({
151
+ session_id: params.sessionId,
152
+ client_id: params.clientId,
153
+ client_secret: params.clientSecret,
154
+ issuer: params.issuer
155
+ })
156
+ });
157
+ if (response.status === 401 || response.status === 403 || response.status === 404) {
158
+ return null;
159
+ }
160
+ if (!response.ok) {
161
+ throw new Error(`OAuth42 backend session resolve failed: ${response.status}`);
162
+ }
163
+ return response.json();
164
+ }
165
+ async function revokeBackendSession(params) {
166
+ await fetch(sessionEndpoint(params.issuer), {
167
+ method: "DELETE",
168
+ headers: { "Content-Type": "application/json" },
169
+ body: JSON.stringify({
170
+ session_id: params.sessionId,
171
+ client_id: params.clientId,
172
+ client_secret: params.clientSecret
173
+ })
174
+ });
175
+ }
176
+
119
177
  // src/server/auth.ts
120
178
  var NextAuth = NextAuthDefault.default || NextAuthDefault;
121
179
  var activeRefresh = null;
122
180
  function createAuth(options = {}) {
123
181
  const clientId = options.clientId || process.env.OAUTH42_CLIENT_ID;
124
182
  const clientSecret = options.clientSecret || process.env.OAUTH42_CLIENT_SECRET;
183
+ const issuer = options.issuer || process.env.NEXT_PUBLIC_OAUTH_ISSUER || process.env.OAUTH42_ISSUER || "https://localhost:8443";
184
+ const useBackendSessions = isBackendSessionMode(options.sessionStore);
125
185
  if (!clientId || !clientSecret) {
126
186
  throw new Error(
127
187
  "OAuth42 client credentials are required. Set OAUTH42_CLIENT_ID and OAUTH42_CLIENT_SECRET environment variables or pass them in the options."
@@ -132,7 +192,7 @@ function createAuth(options = {}) {
132
192
  OAuth42Provider({
133
193
  clientId,
134
194
  clientSecret,
135
- issuer: options.issuer,
195
+ issuer,
136
196
  scopes: options.scopes,
137
197
  pkceEnabled: options.pkceEnabled
138
198
  })
@@ -141,14 +201,41 @@ function createAuth(options = {}) {
141
201
  async jwt({ token, account, profile }) {
142
202
  console.log("[OAuth42 SDK] JWT callback called", { hasAccount: !!account, hasProfile: !!profile });
143
203
  if (account) {
144
- console.log("[OAuth42 SDK] Initial sign in - storing tokens in JWT");
145
- token.accessToken = account.access_token;
146
- token.refreshToken = account.refresh_token;
147
- token.expiresAt = account.expires_at;
148
- token.idToken = account.id_token;
149
- token.clientId = clientId;
150
- token.clientSecret = clientSecret;
151
- token.issuer = options.issuer || process.env.NEXT_PUBLIC_OAUTH_ISSUER || process.env.OAUTH42_ISSUER;
204
+ if (useBackendSessions) {
205
+ if (!account.access_token || !account.refresh_token) {
206
+ throw new Error("OAuth42 backend sessions require access and refresh tokens");
207
+ }
208
+ const oauth42Profile = profile;
209
+ const backendSession = await createBackendSession({
210
+ issuer,
211
+ clientId,
212
+ clientSecret,
213
+ accessToken: account.access_token,
214
+ refreshToken: account.refresh_token,
215
+ expiresAt: account.expires_at,
216
+ idToken: account.id_token,
217
+ user: {
218
+ sub: oauth42Profile?.sub,
219
+ email: oauth42Profile?.email,
220
+ username: oauth42Profile?.username,
221
+ name: token.name,
222
+ emailVerified: oauth42Profile?.email_verified,
223
+ groups: oauth42Profile?.groups
224
+ }
225
+ });
226
+ token.oauth42SessionId = backendSession.session_id;
227
+ token.expiresAt = backendSession.expires_at;
228
+ token.issuer = issuer;
229
+ } else {
230
+ console.log("[OAuth42 SDK] Initial sign in - storing tokens in JWT");
231
+ token.accessToken = account.access_token;
232
+ token.refreshToken = account.refresh_token;
233
+ token.expiresAt = account.expires_at;
234
+ token.idToken = account.id_token;
235
+ token.clientId = clientId;
236
+ token.clientSecret = clientSecret;
237
+ token.issuer = issuer;
238
+ }
152
239
  }
153
240
  if (profile) {
154
241
  const oauth42Profile = profile;
@@ -165,12 +252,40 @@ function createAuth(options = {}) {
165
252
  },
166
253
  async session({ session, token }) {
167
254
  console.log("[OAuth42 SDK] Session callback called", { hasToken: !!token, hasSession: !!session });
168
- session.accessToken = token.accessToken;
169
- session.idToken = token.idToken;
255
+ if (useBackendSessions) {
256
+ const sessionId = token.oauth42SessionId;
257
+ if (!sessionId) {
258
+ session.error = "SessionRevoked";
259
+ return session;
260
+ }
261
+ const backendSession = await resolveBackendSession({
262
+ issuer,
263
+ clientId,
264
+ clientSecret,
265
+ sessionId
266
+ });
267
+ if (!backendSession) {
268
+ session.error = "SessionRevoked";
269
+ return session;
270
+ }
271
+ session.accessToken = backendSession.access_token;
272
+ session.idToken = backendSession.id_token;
273
+ token.expiresAt = backendSession.expires_at;
274
+ if (backendSession.user && session.user) {
275
+ session.user.email = backendSession.user.email;
276
+ session.user.name = backendSession.user.name;
277
+ session.user.username = backendSession.user.username;
278
+ session.user.emailVerified = backendSession.user.emailVerified;
279
+ session.user.groups = backendSession.user.groups;
280
+ }
281
+ } else {
282
+ session.accessToken = token.accessToken;
283
+ session.idToken = token.idToken;
284
+ }
170
285
  if (token.error) {
171
286
  session.error = token.error;
172
287
  }
173
- if (session.user) {
288
+ if (!useBackendSessions && session.user) {
174
289
  session.user.email = token.email;
175
290
  session.user.name = token.name;
176
291
  session.user.username = token.username;
@@ -194,6 +309,26 @@ function createAuth(options = {}) {
194
309
  strategy: "jwt",
195
310
  ...options.session
196
311
  },
312
+ events: {
313
+ async signOut(message) {
314
+ if (useBackendSessions) {
315
+ const sessionId = message.token?.oauth42SessionId;
316
+ if (sessionId) {
317
+ await revokeBackendSession({
318
+ issuer,
319
+ clientId,
320
+ clientSecret,
321
+ sessionId
322
+ }).catch((error) => {
323
+ console.warn("[OAuth42 SDK] Failed to revoke backend session on signOut:", error);
324
+ });
325
+ }
326
+ }
327
+ if (options.events?.signOut) {
328
+ await options.events.signOut(message);
329
+ }
330
+ }
331
+ },
197
332
  debug: options.debug || process.env.NODE_ENV === "development",
198
333
  secret: process.env.NEXTAUTH_SECRET,
199
334
  // Configure unique cookie names per app to prevent session conflicts on localhost
@@ -226,7 +361,11 @@ function createAuth(options = {}) {
226
361
  secure: process.env.NODE_ENV === "production"
227
362
  }
228
363
  },
229
- // PKCE code_verifier cookie - essential for PKCE flow
364
+ // PKCE code_verifier cookie - essential for PKCE flow.
365
+ // TTL covers the full OAuth round-trip including user-in-the-loop steps
366
+ // like passkey ceremonies, MFA enrollment, and any debugging pauses.
367
+ // The previous 15-minute value was too tight for realistic ceremony-
368
+ // heavy flows — see #524 for the incident where state expired mid-debug.
230
369
  pkceCodeVerifier: {
231
370
  name: `${options.cookiePrefix}.pkce.code_verifier`,
232
371
  options: {
@@ -234,11 +373,13 @@ function createAuth(options = {}) {
234
373
  sameSite: "lax",
235
374
  path: "/",
236
375
  secure: process.env.NODE_ENV === "production",
237
- maxAge: 900
238
- // 15 minutes
376
+ maxAge: 3600
377
+ // 1 hour (was 900 — 15 min was too short for passkey/MFA flows)
239
378
  }
240
379
  },
241
- // State cookie for OAuth CSRF protection
380
+ // State cookie for OAuth CSRF protection. Same TTL rationale as
381
+ // pkceCodeVerifier above. State is single-use CSRF and cannot be
382
+ // replayed, so a longer TTL does not weaken the security story.
242
383
  state: {
243
384
  name: `${options.cookiePrefix}.state`,
244
385
  options: {
@@ -246,8 +387,8 @@ function createAuth(options = {}) {
246
387
  sameSite: "lax",
247
388
  path: "/",
248
389
  secure: process.env.NODE_ENV === "production",
249
- maxAge: 900
250
- // 15 minutes
390
+ maxAge: 3600
391
+ // 1 hour (was 900 — 15 min was too short for passkey/MFA flows)
251
392
  }
252
393
  },
253
394
  // Nonce cookie for OpenID Connect
@@ -341,7 +482,22 @@ import { getToken, encode } from "next-auth/jwt";
341
482
  var ALLOWED_COOKIE_SIZE = 4096;
342
483
  var ESTIMATED_EMPTY_COOKIE_SIZE = 163;
343
484
  var CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE;
485
+ var REFRESH_RACE_GRACE_SECONDS = 60;
486
+ var DEFAULT_SESSION_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
344
487
  var pendingRefreshes = /* @__PURE__ */ new Map();
488
+ function shouldPreserveSessionOnRefreshFailure(params) {
489
+ if (params.error !== "invalid_grant" || !params.expiresAt) {
490
+ return false;
491
+ }
492
+ return params.now - params.expiresAt <= REFRESH_RACE_GRACE_SECONDS;
493
+ }
494
+ function resolveRefreshConfig(options, token) {
495
+ return {
496
+ clientId: options.clientId || token.clientId || process.env.OAUTH42_CLIENT_ID,
497
+ clientSecret: options.clientSecret || token.clientSecret || process.env.OAUTH42_CLIENT_SECRET,
498
+ issuer: options.issuer || token.issuer || process.env.OAUTH42_ISSUER || "https://localhost:8443"
499
+ };
500
+ }
345
501
  async function refreshTokens(refreshToken, clientId, clientSecret, issuer) {
346
502
  try {
347
503
  const tokenUrl = `${issuer}/oauth2/token`;
@@ -376,9 +532,6 @@ async function refreshTokens(refreshToken, clientId, clientSecret, issuer) {
376
532
  }
377
533
  function withOAuth42Auth(options = {}) {
378
534
  const secret = process.env.NEXTAUTH_SECRET;
379
- const clientId = process.env.OAUTH42_CLIENT_ID;
380
- const clientSecret = process.env.OAUTH42_CLIENT_SECRET;
381
- const issuer = process.env.OAUTH42_ISSUER || "https://localhost:8443";
382
535
  if (!secret) {
383
536
  console.warn("[OAuth42 Middleware] NEXTAUTH_SECRET not set");
384
537
  }
@@ -403,6 +556,42 @@ function withOAuth42Auth(options = {}) {
403
556
  url.searchParams.set("callbackUrl", pathname);
404
557
  return NextResponse.redirect(url);
405
558
  }
559
+ const refreshConfig = resolveRefreshConfig(options, token);
560
+ if (isBackendSessionMode(options.sessionStore)) {
561
+ const sessionId = token.oauth42SessionId;
562
+ if (!sessionId || !refreshConfig.clientId || !refreshConfig.clientSecret) {
563
+ const signInUrl = options.pages?.signIn || "/auth/signin";
564
+ const url = new URL(signInUrl, req.url);
565
+ url.searchParams.set("callbackUrl", pathname);
566
+ const response = NextResponse.redirect(url);
567
+ response.cookies.delete(cookieName);
568
+ return response;
569
+ }
570
+ const backendSession = await resolveBackendSession({
571
+ issuer: refreshConfig.issuer,
572
+ clientId: refreshConfig.clientId,
573
+ clientSecret: refreshConfig.clientSecret,
574
+ sessionId
575
+ }).catch((error) => {
576
+ console.error("[OAuth42 Middleware] Backend session resolve failed:", error);
577
+ return null;
578
+ });
579
+ if (!backendSession) {
580
+ const signInUrl = options.pages?.signIn || "/auth/signin";
581
+ const url = new URL(signInUrl, req.url);
582
+ url.searchParams.set("callbackUrl", pathname);
583
+ const response = NextResponse.redirect(url);
584
+ response.cookies.delete(cookieName);
585
+ return response;
586
+ }
587
+ const requestHeaders = new Headers(req.headers);
588
+ requestHeaders.set("x-oauth42-refreshed-token", backendSession.access_token);
589
+ return NextResponse.next({
590
+ request: {
591
+ headers: requestHeaders
592
+ }
593
+ });
594
+ }
406
595
  const expiresAt = token.expiresAt;
407
596
  const now = Math.floor(Date.now() / 1e3);
408
597
  const bufferSeconds = 15;
@@ -416,7 +605,7 @@ function withOAuth42Auth(options = {}) {
416
605
  path: pathname
417
606
  });
418
607
  }
419
- if (needsRefresh && token.refreshToken && clientId && clientSecret) {
608
+ if (needsRefresh && token.refreshToken && refreshConfig.clientId && refreshConfig.clientSecret) {
420
609
  const userId = token.sub;
421
610
  let refreshPromise = pendingRefreshes.get(userId);
422
611
  if (refreshPromise) {
@@ -425,11 +614,14 @@ function withOAuth42Auth(options = {}) {
425
614
  console.log("[OAuth42 Middleware] Access token expired, refreshing...");
426
615
  refreshPromise = refreshTokens(
427
616
  token.refreshToken,
428
- clientId,
429
- clientSecret,
430
- issuer
617
+ refreshConfig.clientId,
618
+ refreshConfig.clientSecret,
619
+ refreshConfig.issuer
431
620
  ).finally(() => {
432
- setTimeout(() => pendingRefreshes.delete(userId), 3e4);
621
+ const cleanupTimer = setTimeout(() => pendingRefreshes.delete(userId), 3e4);
622
+ if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
623
+ cleanupTimer.unref();
624
+ }
433
625
  });
434
626
  pendingRefreshes.set(userId, refreshPromise);
435
627
  }
@@ -456,7 +648,8 @@ function withOAuth42Auth(options = {}) {
456
648
  httpOnly: true,
457
649
  sameSite: "lax",
458
650
  path: "/",
459
- secure: process.env.NODE_ENV === "production"
651
+ secure: process.env.NODE_ENV === "production",
652
+ maxAge: options.session?.maxAge ?? DEFAULT_SESSION_MAX_AGE_SECONDS
460
653
  };
461
654
  if (newJwt.length <= CHUNK_SIZE) {
462
655
  response.cookies.set(cookieName, newJwt, cookieOptions);
@@ -504,7 +697,23 @@ function withOAuth42Auth(options = {}) {
504
697
  });
505
698
  return response;
506
699
  } else {
507
- console.log("[OAuth42 Middleware] Refresh failed (likely token blacklisted after deploy), clearing session");
700
+ if (shouldPreserveSessionOnRefreshFailure({
701
+ error: refreshed.error,
702
+ expiresAt,
703
+ now
704
+ })) {
705
+ console.warn(
706
+ "[OAuth42 Middleware] Refresh failed with invalid_grant for a recently expired token; preserving session cookie for likely parallel refresh race",
707
+ {
708
+ expiresAt,
709
+ now,
710
+ expiredAgo: now - expiresAt,
711
+ path: pathname
712
+ }
713
+ );
714
+ return NextResponse.next();
715
+ }
716
+ console.log("[OAuth42 Middleware] Refresh failed, clearing session");
508
717
  const signInUrl = options.pages?.signIn || "/auth/signin";
509
718
  const url = new URL(signInUrl, req.url);
510
719
  url.searchParams.set("callbackUrl", pathname);
@@ -650,12 +859,15 @@ export {
650
859
  default2 as NextAuth,
651
860
  OAuth42Provider,
652
861
  createAuth,
862
+ createBackendSession,
653
863
  createHandlers,
654
864
  createHostedAuthCallback,
655
865
  createMiddlewareConfig,
656
866
  getOAuth42Session,
657
867
  getServerSession,
658
868
  refreshAccessToken,
869
+ resolveBackendSession,
870
+ revokeBackendSession,
659
871
  withOAuth42Auth,
660
872
  withOAuth42ServerSideProps,
661
873
  withOAuth42Session