UNPKG

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
// 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 };