coyotte508 HF Staff commited on
Commit
ab8b948
·
unverified ·
1 Parent(s): 248183e

Automatically refresh oauth token (#1913)

Browse files
src/lib/migrations/lock.ts CHANGED
@@ -5,7 +5,7 @@ import type { Semaphores } from "$lib/types/Semaphore";
5
  /**
6
  * Returns the lock id if the lock was acquired, false otherwise
7
  */
8
- export async function acquireLock(key: Semaphores): Promise<ObjectId | false> {
9
  try {
10
  const id = new ObjectId();
11
 
@@ -24,21 +24,21 @@ export async function acquireLock(key: Semaphores): Promise<ObjectId | false> {
24
  }
25
  }
26
 
27
- export async function releaseLock(key: Semaphores, lockId: ObjectId) {
28
  await collections.semaphores.deleteOne({
29
  _id: lockId,
30
  key,
31
  });
32
  }
33
 
34
- export async function isDBLocked(key: Semaphores): Promise<boolean> {
35
  const res = await collections.semaphores.countDocuments({
36
  key,
37
  });
38
  return res > 0;
39
  }
40
 
41
- export async function refreshLock(key: Semaphores, lockId: ObjectId): Promise<boolean> {
42
  const result = await collections.semaphores.updateOne(
43
  {
44
  _id: lockId,
 
5
  /**
6
  * Returns the lock id if the lock was acquired, false otherwise
7
  */
8
+ export async function acquireLock(key: Semaphores | string): Promise<ObjectId | false> {
9
  try {
10
  const id = new ObjectId();
11
 
 
24
  }
25
  }
26
 
27
+ export async function releaseLock(key: Semaphores | string, lockId: ObjectId) {
28
  await collections.semaphores.deleteOne({
29
  _id: lockId,
30
  key,
31
  });
32
  }
33
 
34
+ export async function isDBLocked(key: Semaphores | string): Promise<boolean> {
35
  const res = await collections.semaphores.countDocuments({
36
  key,
37
  });
38
  return res > 0;
39
  }
40
 
41
+ export async function refreshLock(key: Semaphores | string, lockId: ObjectId): Promise<boolean> {
42
  const result = await collections.semaphores.updateOne(
43
  {
44
  _id: lockId,
src/lib/server/auth.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
  type TokenSet,
6
  custom,
7
  } from "openid-client";
8
- import { addHours, addWeeks } from "date-fns";
9
  import { config } from "$lib/server/config";
10
  import { sha256 } from "$lib/utils/sha256";
11
  import { z } from "zod";
@@ -20,6 +20,8 @@ import { adminTokenManager } from "./adminToken";
20
  import type { User } from "$lib/types/User";
21
  import type { Session } from "$lib/types/Session";
22
  import { base } from "$app/paths";
 
 
23
 
24
  export interface OIDCSettings {
25
  redirectURI: string;
@@ -93,6 +95,56 @@ export async function findUser(
93
  return { user: null, invalidateSession: true };
94
  }
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  return {
97
  user: await collections.users.findOne({ _id: session.userId }),
98
  invalidateSession: false,
@@ -109,6 +161,22 @@ export const authCondition = (locals: App.Locals) => {
109
  : { sessionId: locals.sessionId, userId: { $exists: false } };
110
  };
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /**
113
  * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
114
  */
@@ -126,8 +194,23 @@ export async function generateCsrfToken(sessionId: string, redirectUrl: string):
126
  ).toString("base64");
127
  }
128
 
 
 
129
  async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
130
- const issuer = await Issuer.discover(OIDConfig.PROVIDER_URL);
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  const client_config: ConstructorParameters<typeof issuer.Client>[0] = {
133
  client_id: OIDConfig.CLIENT_ID,
@@ -173,6 +256,18 @@ export async function getOIDCUserData(
173
  return { token, userData };
174
  }
175
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  export async function validateAndParseCsrfToken(
177
  token: string,
178
  sessionId: string
 
5
  type TokenSet,
6
  custom,
7
  } from "openid-client";
8
+ import { addHours, addWeeks, differenceInMinutes, subMinutes } from "date-fns";
9
  import { config } from "$lib/server/config";
10
  import { sha256 } from "$lib/utils/sha256";
11
  import { z } from "zod";
 
20
  import type { User } from "$lib/types/User";
21
  import type { Session } from "$lib/types/Session";
22
  import { base } from "$app/paths";
23
+ import { acquireLock, releaseLock } from "$lib/migrations/lock";
24
+ import { Semaphores } from "$lib/types/Semaphore";
25
 
26
  export interface OIDCSettings {
27
  redirectURI: string;
 
95
  return { user: null, invalidateSession: true };
96
  }
97
 
98
+ // Check if OAuth token needs refresh
99
+ if (session.oauth?.token && session.oauth.refreshToken) {
100
+ // If token expires in less than 5 minutes, refresh it
101
+ if (differenceInMinutes(session.oauth.token.expiresAt, new Date()) < 5) {
102
+ const lockKey = `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`;
103
+
104
+ // Acquire lock for token refresh
105
+ const lockId = await acquireLock(lockKey);
106
+ if (lockId) {
107
+ try {
108
+ // Attempt to refresh the token
109
+ const newTokenSet = await refreshOAuthToken(
110
+ { redirectURI: `${config.PUBLIC_ORIGIN}${base}/login/callback` },
111
+ session.oauth.refreshToken
112
+ );
113
+
114
+ if (!newTokenSet || !newTokenSet.access_token) {
115
+ // Token refresh failed, invalidate session
116
+ return { user: null, invalidateSession: true };
117
+ }
118
+
119
+ // Update session with new token information
120
+ const updatedOAuth = tokenSetToSessionOauth(newTokenSet);
121
+
122
+ if (!updatedOAuth) {
123
+ // Token refresh failed, invalidate session
124
+ return { user: null, invalidateSession: true };
125
+ }
126
+
127
+ await collections.sessions.updateOne(
128
+ { sessionId },
129
+ {
130
+ $set: {
131
+ oauth: updatedOAuth,
132
+ updatedAt: new Date(),
133
+ },
134
+ }
135
+ );
136
+
137
+ session.oauth = updatedOAuth;
138
+ } catch (err) {
139
+ logger.error("Error during token refresh:", err);
140
+ return { user: null, invalidateSession: true };
141
+ } finally {
142
+ await releaseLock(lockKey, lockId);
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
  return {
149
  user: await collections.users.findOne({ _id: session.userId }),
150
  invalidateSession: false,
 
161
  : { sessionId: locals.sessionId, userId: { $exists: false } };
162
  };
163
 
164
+ export function tokenSetToSessionOauth(tokenSet: TokenSet): Session["oauth"] {
165
+ if (!tokenSet.access_token) {
166
+ return undefined;
167
+ }
168
+
169
+ return {
170
+ token: {
171
+ value: tokenSet.access_token,
172
+ expiresAt: tokenSet.expires_at
173
+ ? subMinutes(new Date(tokenSet.expires_at * 1000), 1)
174
+ : addWeeks(new Date(), 2),
175
+ },
176
+ refreshToken: tokenSet.refresh_token || undefined,
177
+ };
178
+ }
179
+
180
  /**
181
  * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
182
  */
 
194
  ).toString("base64");
195
  }
196
 
197
+ let lastIssuer: Issuer<BaseClient> | null = null;
198
+ let lastIssuerFetchedAt: Date | null = null;
199
  async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
200
+ if (
201
+ lastIssuer &&
202
+ lastIssuerFetchedAt &&
203
+ differenceInMinutes(new Date(), lastIssuerFetchedAt) >= 10
204
+ ) {
205
+ lastIssuer = null;
206
+ lastIssuerFetchedAt = null;
207
+ }
208
+ if (!lastIssuer) {
209
+ lastIssuer = await Issuer.discover(OIDConfig.PROVIDER_URL);
210
+ lastIssuerFetchedAt = new Date();
211
+ }
212
+
213
+ const issuer = lastIssuer;
214
 
215
  const client_config: ConstructorParameters<typeof issuer.Client>[0] = {
216
  client_id: OIDConfig.CLIENT_ID,
 
256
  return { token, userData };
257
  }
258
 
259
+ /**
260
+ * Refreshes an OAuth token using the refresh token
261
+ */
262
+ export async function refreshOAuthToken(
263
+ settings: OIDCSettings,
264
+ refreshToken: string
265
+ ): Promise<TokenSet | null> {
266
+ const client = await getOIDCClient(settings);
267
+ const tokenSet = await client.refresh(refreshToken);
268
+ return tokenSet;
269
+ }
270
+
271
  export async function validateAndParseCsrfToken(
272
  token: string,
273
  sessionId: string
src/lib/types/Semaphore.ts CHANGED
@@ -10,4 +10,10 @@ export enum Semaphores {
10
  CONFIG_UPDATE = "config.update",
11
  MIGRATION = "migration",
12
  TEST_MIGRATION = "test.migration",
 
 
 
 
 
 
13
  }
 
10
  CONFIG_UPDATE = "config.update",
11
  MIGRATION = "migration",
12
  TEST_MIGRATION = "test.migration",
13
+ /**
14
+ * Note this lock name is used as `${Semaphores.OAUTH_TOKEN_REFRESH}:${sessionId}`
15
+ *
16
+ * not a global lock, but a lock for each session
17
+ */
18
+ OAUTH_TOKEN_REFRESH = "oauth.token.refresh",
19
  }
src/routes/login/callback/updateUser.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { getCoupledCookieHash, refreshSessionCookie } from "$lib/server/auth";
 
 
 
 
2
  import { collections } from "$lib/server/database";
3
  import { ObjectId } from "mongodb";
4
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
@@ -7,7 +11,7 @@ import type { UserinfoResponse, TokenSet } from "openid-client";
7
  import { error, type Cookies } from "@sveltejs/kit";
8
  import crypto from "crypto";
9
  import { sha256 } from "$lib/utils/sha256";
10
- import { addWeeks, subMinutes } from "date-fns";
11
  import { OIDConfig } from "$lib/server/auth";
12
  import { config } from "$lib/server/config";
13
  import { logger } from "$lib/server/logger";
@@ -124,19 +128,7 @@ export async function updateUser(params: {
124
  const coupledCookieHash = await getCoupledCookieHash({ type: "svelte", value: cookies });
125
 
126
  // Prepare OAuth token data for session storage
127
- const oauthData = token.access_token
128
- ? {
129
- token: {
130
- value: token.access_token,
131
- expiresAt: token.expires_at
132
- ? subMinutes(new Date(token.expires_at * 1000), 1)
133
- : token.expires_in
134
- ? subMinutes(new Date(Date.now() + token.expires_in * 1000), 1)
135
- : addWeeks(new Date(), 2),
136
- },
137
- ...(token.refresh_token ? { refreshToken: token.refresh_token } : {}),
138
- }
139
- : undefined;
140
 
141
  if (existingUser) {
142
  // update existing user if any
 
1
+ import {
2
+ getCoupledCookieHash,
3
+ refreshSessionCookie,
4
+ tokenSetToSessionOauth,
5
+ } from "$lib/server/auth";
6
  import { collections } from "$lib/server/database";
7
  import { ObjectId } from "mongodb";
8
  import { DEFAULT_SETTINGS } from "$lib/types/Settings";
 
11
  import { error, type Cookies } from "@sveltejs/kit";
12
  import crypto from "crypto";
13
  import { sha256 } from "$lib/utils/sha256";
14
+ import { addWeeks } from "date-fns";
15
  import { OIDConfig } from "$lib/server/auth";
16
  import { config } from "$lib/server/config";
17
  import { logger } from "$lib/server/logger";
 
128
  const coupledCookieHash = await getCoupledCookieHash({ type: "svelte", value: cookies });
129
 
130
  // Prepare OAuth token data for session storage
131
+ const oauthData = tokenSetToSessionOauth(token);
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  if (existingUser) {
134
  // update existing user if any