Automatically refresh oauth token (#1913)
Browse files- src/lib/migrations/lock.ts +4 -4
- src/lib/server/auth.ts +97 -2
- src/lib/types/Semaphore.ts +6 -0
- src/routes/login/callback/updateUser.ts +7 -15
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|