svoauth
Version:
`svoauth` is a minimal and flexible OAuth 2.0 wrapper designed for SvelteKit projects. It uses a config-driven approach so you can easily plug in providers like GitHub, Google, and more.
262 lines (257 loc) • 8.15 kB
JavaScript
// src/helpers/index.ts
var isServer = () => {
if (typeof window !== "undefined") {
throw new Error("This package should only be used in your server-side code, please refer to the README for more information.");
}
};
// src/helpers/crypto.ts
import crypto from "crypto";
var generateCodeVerifier = () => {
return crypto.randomBytes(32).toString("base64url");
};
var generateCodeChallenge = (verifier) => {
return crypto.createHash("sha256").update(verifier).digest("base64url");
};
var generateState = () => {
return crypto.randomBytes(32).toString("hex");
};
var basicCredentialsEncode = (username, password) => {
const bytes = new TextEncoder().encode(`${username}:${password}`);
const binary = String.fromCharCode(...bytes);
return btoa(binary);
};
// src/index.ts
isServer();
class OAuthInstance {
#client;
constructor(client) {
this.#client = client;
if (this.#client.basicAuth === undefined) {
this.#client.basicAuth = true;
}
}
#verifyState(cookies, returnedState) {
const storedState = cookies.get("oauth_state");
cookies.delete("oauth_state", { path: "/" });
if (!storedState || storedState !== returnedState) {
throw new Error("Invalid state parameter - possible CSRF attack");
}
return true;
}
#parseCallbackUrl(url, cookies) {
const params = new URLSearchParams(url.split("?")[1]);
const state = params.get("state");
const code = params.get("code");
if (!state || !code) {
throw new Error("Invalid callback URL: missing code or state");
}
this.#verifyState(cookies, state);
return code;
}
#parseTokenResponse(response) {
return {
...response,
hasAccessToken() {
return !!response.access_token;
},
hasRefreshToken() {
return !!response.refresh_token;
},
idToken() {
return response.id_token;
},
accessToken() {
if (!response.access_token)
throw Error("No access token found");
return response.access_token;
},
refreshToken() {
if (!response.refresh_token)
throw Error("No refresh token found");
return response.refresh_token;
},
expiresAt() {
const { expires_in, expires_at } = response;
if (typeof expires_in === "number" && Number.isFinite(expires_in) && expires_in > 0) {
return new Date(Date.now() + expires_in * 1000);
}
if (expires_at) {
if (typeof expires_at === "string") {
const date = new Date(expires_at);
return isNaN(date.getTime()) ? undefined : date;
}
if (typeof expires_at === "number" && Number.isFinite(expires_at)) {
return new Date(expires_at * 1000);
}
}
return;
}
};
}
generateAuthorizeUrl(event) {
const state = generateState();
const { cookies } = event;
const params = new URLSearchParams({
client_id: this.#client.clientId,
redirect_uri: this.#client.redirectUri,
response_type: "code"
});
if (this.#client.scopes.values.length > 0) {
params.set("scope", this.#client.scopes.values.join(this.#client.scopes.delimiter ?? " "));
}
if (state) {
cookies.set("oauth_state", state, {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 10 * 60,
path: "/"
});
console.debug("oauth_state cookie set");
params.set("state", state);
}
if (this.#client.pkce) {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
cookies.set("oauth_code_verifier", codeVerifier, {
httpOnly: true,
secure: false,
sameSite: "lax",
maxAge: 10 * 60,
path: "/"
});
console.debug("oauth_code_verifier cookie set");
params.set("code_challenge", codeChallenge);
params.set("code_challenge_method", "S256");
}
if (this.#client.params) {
this.#client.params.forEach((param) => {
const [[key, value]] = Object.entries(param);
params.set(key, value);
});
}
return `${this.#client.authorizeUrl}?${params.toString()}`;
}
async exchangeCodeForToken(event) {
if (!this.#client) {
throw new Error(`Client "${this.#client}" not found.`);
}
const { cookies, url } = event;
const code = this.#parseCallbackUrl(url.toString(), cookies);
const params = new URLSearchParams({
client_id: this.#client.clientId,
scope: this.#client.scopes.values.join(this.#client.scopes.delimiter ?? " "),
code,
redirect_uri: this.#client.redirectUri,
grant_type: "authorization_code"
});
if (this.#client.pkce) {
const codeVerifier = cookies.get("oauth_code_verifier");
cookies.delete("oauth_code_verifier", { path: "/" });
if (!codeVerifier) {
throw new Error("Code verifier not found");
}
params.set("code_verifier", codeVerifier);
}
params.set("client_secret", this.#client.clientSecret);
const basicAuth = this.#client.basicAuth ? {
Authorization: `Basic ${basicCredentialsEncode(this.#client.clientId, this.#client.clientSecret)}`
} : {};
const response = await fetch(this.#client.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"User-Agent": "svoauth",
...basicAuth
},
body: params.toString()
});
if (!response.ok) {
console.error(response);
console.error(await response.text());
throw new Error("Failed to exchange code for token");
}
const tokenResponse = await response.json();
return this.#parseTokenResponse(tokenResponse);
}
async refreshToken(refreshToken) {
if (!this.#client) {
throw new Error(`Client "${this.#client}" not found.`);
}
if (!refreshToken) {
throw new Error("Refresh token not found");
}
if (!this.#client.refreshTokenUrl) {
console.warn("Refresh token url not found");
return null;
}
const params = new URLSearchParams({
client_id: this.#client.clientId,
client_secret: this.#client.clientSecret,
refresh_token: refreshToken,
grant_type: "refresh_token"
});
const basicAuth = this.#client.basicAuth ? {
Authorization: `Basic ${basicCredentialsEncode(this.#client.clientId, this.#client.clientSecret)}`
} : {};
const response = await fetch(this.#client.refreshTokenUrl || this.#client.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...basicAuth
},
body: params.toString()
});
if (!response.ok) {
throw new Error("Failed to refresh token");
}
const tokenResponse = await response.json();
return this.#parseTokenResponse(tokenResponse);
}
async revokeToken(token) {
if (!this.#client) {
throw new Error(`Client "${this.#client}" not found.`);
}
if (!token) {
throw new Error("Token not found");
}
if (!this.#client.revokeTokenUrl) {
console.error("Revoke token URL not found");
throw new Error("Revoke token URL not configured");
}
const params = new URLSearchParams({
token
});
const response = await fetch(this.#client.revokeTokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"Accept-Encoding": "application/json"
},
body: params.toString()
});
if (!response.ok) {
console.error(response);
throw new Error("Failed to revoke token");
}
return true;
}
}
class OAuthHandler {
#clients;
constructor(clients) {
this.#clients = new Map(Object.entries(clients));
}
get(clientName) {
const hasFound = this.#clients.get(clientName);
if (!hasFound) {
throw new Error(`Client "${clientName}" not found.`);
}
return new OAuthInstance(hasFound);
}
}
export {
OAuthHandler
};