UNPKG

alinea

Version:
695 lines (689 loc) 24.5 kB
import { parse } from "../../chunks/chunk-5LTN67OE.js"; import { PLazy } from "../../chunks/chunk-IKINPSS5.js"; import "../../chunks/chunk-NZLE2WMY.js"; // src/backend/api/OAuth2.ts import { Request as Request2, Response } from "@alinea/iso"; // node_modules/@badgateway/oauth2-client/dist/error.js var OAuth2Error = class extends Error { constructor(message, oauth2Code) { super(message); this.oauth2Code = oauth2Code; } }; var OAuth2HttpError = class extends OAuth2Error { constructor(message, oauth2Code, response, parsedBody) { super(message, oauth2Code); this.httpCode = response.status; this.response = response; this.parsedBody = parsedBody; } }; // node_modules/@badgateway/oauth2-client/dist/client/authorization-code.js var OAuth2AuthorizationCodeClient = class { constructor(client) { this.client = client; } /** * Returns the URi that the user should open in a browser to initiate the * authorization_code flow. */ async getAuthorizeUri(params) { const [codeChallenge, authorizationEndpoint] = await Promise.all([ params.codeVerifier ? getCodeChallenge(params.codeVerifier) : void 0, this.client.getEndpoint("authorizationEndpoint") ]); const query = new URLSearchParams({ client_id: this.client.settings.clientId, response_type: "code", redirect_uri: params.redirectUri }); if (codeChallenge) { query.set("code_challenge_method", codeChallenge[0]); query.set("code_challenge", codeChallenge[1]); } if (params.state) { query.set("state", params.state); } if (params.scope) { query.set("scope", params.scope.join(" ")); } if (params.resource) for (const resource of [].concat(params.resource)) { query.append("resource", resource); } if (params.responseMode && params.responseMode !== "query") { query.append("response_mode", params.responseMode); } if (params.extraParams) for (const [k, v] of Object.entries(params.extraParams)) { if (query.has(k)) throw new Error(`Property in extraParams would overwrite standard property: ${k}`); query.set(k, v); } return authorizationEndpoint + "?" + query.toString(); } async getTokenFromCodeRedirect(url, params) { const { code } = this.validateResponse(url, { state: params.state }); return this.getToken({ code, redirectUri: params.redirectUri, codeVerifier: params.codeVerifier }); } /** * After the user redirected back from the authorization endpoint, the * url will contain a 'code' and other information. * * This function takes the url and validate the response. If the user * redirected back with an error, an error will be thrown. */ validateResponse(url, params) { var _a; url = new URL(url); let queryParams = url.searchParams; if (!queryParams.has("code") && !queryParams.has("error") && url.hash.length > 0) { queryParams = new URLSearchParams(url.hash.slice(1)); } if (queryParams.has("error")) { throw new OAuth2Error((_a = queryParams.get("error_description")) !== null && _a !== void 0 ? _a : "OAuth2 error", queryParams.get("error")); } if (!queryParams.has("code")) throw new Error(`The url did not contain a code parameter ${url}`); if (params.state && params.state !== queryParams.get("state")) { throw new Error(`The "state" parameter in the url did not match the expected value of ${params.state}`); } return { code: queryParams.get("code"), scope: queryParams.has("scope") ? queryParams.get("scope").split(" ") : void 0 }; } /** * Receives an OAuth2 token using 'authorization_code' grant */ async getToken(params) { const body = { grant_type: "authorization_code", code: params.code, redirect_uri: params.redirectUri, code_verifier: params.codeVerifier, resource: params.resource }; return this.client.tokenResponseToOAuth2Token(this.client.request("tokenEndpoint", body)); } }; async function generateCodeVerifier() { const webCrypto = await getWebCrypto(); const arr = new Uint8Array(32); webCrypto.getRandomValues(arr); return base64Url(arr); } async function getCodeChallenge(codeVerifier) { const webCrypto = await getWebCrypto(); return ["S256", base64Url(await webCrypto.subtle.digest("SHA-256", stringToBuffer(codeVerifier)))]; } async function getWebCrypto() { var _a; if (typeof window !== "undefined" && window.crypto) { if (!((_a = window.crypto.subtle) === null || _a === void 0 ? void 0 : _a.digest)) { throw new Error("The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details"); } return window.crypto; } if (typeof self !== "undefined" && self.crypto) { return self.crypto; } const crypto = await import("../../chunks/crypto-HZTUU327.js"); return crypto.webcrypto; } function stringToBuffer(input) { const buf = new Uint8Array(input.length); for (let i = 0; i < input.length; i++) { buf[i] = input.charCodeAt(i) & 255; } return buf; } function base64Url(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // node_modules/@badgateway/oauth2-client/dist/client.js var OAuth2Client = class { constructor(clientSettings) { this.discoveryDone = false; this.serverMetadata = null; if (!(clientSettings === null || clientSettings === void 0 ? void 0 : clientSettings.fetch)) { clientSettings.fetch = fetch.bind(globalThis); } this.settings = clientSettings; } /** * Refreshes an existing token, and returns a new one. */ async refreshToken(token, params) { if (!token.refreshToken) { throw new Error("This token didn't have a refreshToken. It's not possible to refresh this"); } const body = { grant_type: "refresh_token", refresh_token: token.refreshToken }; if (!this.settings.clientSecret) { body.client_id = this.settings.clientId; } if (params === null || params === void 0 ? void 0 : params.scope) body.scope = params.scope.join(" "); if (params === null || params === void 0 ? void 0 : params.resource) body.resource = params.resource; const newToken = await this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", body)); if (!newToken.refreshToken && token.refreshToken) { newToken.refreshToken = token.refreshToken; } return newToken; } /** * Retrieves an OAuth2 token using the client_credentials grant. */ async clientCredentials(params) { var _a; const disallowed = ["client_id", "client_secret", "grant_type", "scope"]; if ((params === null || params === void 0 ? void 0 : params.extraParams) && Object.keys(params.extraParams).filter((key) => disallowed.includes(key)).length > 0) { throw new Error(`The following extraParams are disallowed: '${disallowed.join("', '")}'`); } const body = { grant_type: "client_credentials", scope: (_a = params === null || params === void 0 ? void 0 : params.scope) === null || _a === void 0 ? void 0 : _a.join(" "), resource: params === null || params === void 0 ? void 0 : params.resource, ...params === null || params === void 0 ? void 0 : params.extraParams }; if (!this.settings.clientSecret) { throw new Error("A clientSecret must be provided to use client_credentials"); } return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", body)); } /** * Retrieves an OAuth2 token using the 'password' grant'. */ async password(params) { var _a; const body = { grant_type: "password", ...params, scope: (_a = params.scope) === null || _a === void 0 ? void 0 : _a.join(" ") }; return this.tokenResponseToOAuth2Token(this.request("tokenEndpoint", body)); } /** * Returns the helper object for the `authorization_code` grant. */ get authorizationCode() { return new OAuth2AuthorizationCodeClient(this); } /** * Introspect a token * * This will give information about the validity, owner, which client * created the token and more. * * @see https://datatracker.ietf.org/doc/html/rfc7662 */ async introspect(token) { const body = { token: token.accessToken, token_type_hint: "access_token" }; return this.request("introspectionEndpoint", body); } /** * Revoke a token * * This will revoke a token, provided that the server supports this feature. * * @see https://datatracker.ietf.org/doc/html/rfc7009 */ async revoke(token, tokenTypeHint = "access_token") { let tokenValue = token.accessToken; if (tokenTypeHint === "refresh_token") { tokenValue = token.refreshToken; } const body = { token: tokenValue, token_type_hint: tokenTypeHint }; return this.request("revocationEndpoint", body); } /** * Returns a url for an OAuth2 endpoint. * * Potentially fetches a discovery document to get it. */ async getEndpoint(endpoint) { if (this.settings[endpoint] !== void 0) { return resolve(this.settings[endpoint], this.settings.server); } if (endpoint !== "discoveryEndpoint") { await this.discover(); if (this.settings[endpoint] !== void 0) { return resolve(this.settings[endpoint], this.settings.server); } } if (!this.settings.server) { throw new Error(`Could not determine the location of ${endpoint}. Either specify ${endpoint} in the settings, or the "server" endpoint to let the client discover it.`); } switch (endpoint) { case "authorizationEndpoint": return resolve("/authorize", this.settings.server); case "tokenEndpoint": return resolve("/token", this.settings.server); case "discoveryEndpoint": return resolve("/.well-known/oauth-authorization-server", this.settings.server); case "introspectionEndpoint": return resolve("/introspect", this.settings.server); case "revocationEndpoint": return resolve("/revoke", this.settings.server); } } /** * Fetches the OAuth2 discovery document */ async discover() { var _a; if (this.discoveryDone) return; this.discoveryDone = true; let discoverUrl; try { discoverUrl = await this.getEndpoint("discoveryEndpoint"); } catch (_err) { console.warn('[oauth2] OAuth2 discovery endpoint could not be determined. Either specify the "server" or "discoveryEndpoint'); return; } const resp = await this.settings.fetch(discoverUrl, { headers: { Accept: "application/json" } }); if (!resp.ok) return; if (!((_a = resp.headers.get("Content-Type")) === null || _a === void 0 ? void 0 : _a.startsWith("application/json"))) { console.warn("[oauth2] OAuth2 discovery endpoint was not a JSON response. Response is ignored"); return; } this.serverMetadata = await resp.json(); const urlMap = [ ["authorization_endpoint", "authorizationEndpoint"], ["token_endpoint", "tokenEndpoint"], ["introspection_endpoint", "introspectionEndpoint"], ["revocation_endpoint", "revocationEndpoint"] ]; if (this.serverMetadata === null) return; for (const [property, setting] of urlMap) { if (!this.serverMetadata[property]) continue; this.settings[setting] = resolve(this.serverMetadata[property], discoverUrl); } if (this.serverMetadata.token_endpoint_auth_methods_supported && !this.settings.authenticationMethod) { for (const method of this.serverMetadata.token_endpoint_auth_methods_supported) { if (method === "client_secret_basic" || method === "client_secret_post") { this.settings.authenticationMethod = method; break; } } } } async request(endpoint, body) { const uri = await this.getEndpoint(endpoint); const headers = { "Content-Type": "application/x-www-form-urlencoded", // Although it shouldn't be needed, Github OAUth2 will return JSON // unless this is set. "Accept": "application/json" }; let authMethod = this.settings.authenticationMethod; if (!this.settings.clientSecret) { authMethod = "client_secret_post"; } if (!authMethod) { authMethod = "client_secret_basic_interop"; } switch (authMethod) { case "client_secret_basic": headers.Authorization = "Basic " + btoa(legacyFormUrlEncode(this.settings.clientId) + ":" + legacyFormUrlEncode(this.settings.clientSecret)); break; case "client_secret_basic_interop": headers.Authorization = "Basic " + btoa(this.settings.clientId.replace(/:/g, "%3A") + ":" + this.settings.clientSecret.replace(/:/g, "%3A")); break; case "client_secret_post": body.client_id = this.settings.clientId; if (this.settings.clientSecret) { body.client_secret = this.settings.clientSecret; } break; default: throw new Error("Authentication method not yet supported:" + authMethod + ". Open a feature request if you want this!"); } const resp = await this.settings.fetch(uri, { method: "POST", body: generateQueryString(body), headers }); let responseBody; if (resp.status !== 204 && resp.headers.has("Content-Type") && resp.headers.get("Content-Type").match(/^application\/(.*\+)?json/)) { responseBody = await resp.json(); } if (resp.ok) { return responseBody; } let errorMessage; let oauth2Code; if (responseBody === null || responseBody === void 0 ? void 0 : responseBody.error) { errorMessage = "OAuth2 error " + responseBody.error + "."; if (responseBody.error_description) { errorMessage += " " + responseBody.error_description; } oauth2Code = responseBody.error; } else { errorMessage = "HTTP Error " + resp.status + " " + resp.statusText; if (resp.status === 401 && this.settings.clientSecret) { errorMessage += ". It's likely that the clientId and/or clientSecret was incorrect"; } oauth2Code = null; } throw new OAuth2HttpError(errorMessage, oauth2Code, resp, responseBody); } /** * Converts the JSON response body from the token endpoint to an OAuth2Token type. */ async tokenResponseToOAuth2Token(resp) { var _a; const body = await resp; if (!(body === null || body === void 0 ? void 0 : body.access_token)) { console.warn("Invalid OAuth2 Token Response: ", body); throw new TypeError("We received an invalid token response from an OAuth2 server."); } const result = { accessToken: body.access_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1e3 : null, refreshToken: (_a = body.refresh_token) !== null && _a !== void 0 ? _a : null }; if (body.id_token) { result.idToken = body.id_token; } return result; } }; function resolve(uri, base) { return new URL(uri, base).toString(); } function generateQueryString(params) { const query = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (Array.isArray(v)) { for (const vItem of v) query.append(k, vItem); } else if (v !== void 0) query.set(k, v.toString()); } return query.toString(); } function legacyFormUrlEncode(value) { return encodeURIComponent(value).replace(/%20/g, "+").replace(/[-_.!~*'()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`); } // src/backend/api/OAuth2.ts import { AuthResultType } from "alinea/cloud/AuthResult"; import { HttpError } from "alinea/core/HttpError"; import { createId } from "alinea/core/Id"; import { outcome } from "alinea/core/Outcome"; import { assert } from "alinea/core/util/Assert"; import { decode, verify } from "alinea/core/util/JWT"; import { AuthAction, InvalidCredentialsError, MissingCredentialsError } from "../Auth.js"; import { router } from "../router/Router.js"; var COOKIE_VERIFIER = "alinea.cv"; var COOKIE_ACCESS_TOKEN = "alinea.at"; var COOKIE_REFRESH_TOKEN = "alinea.rt"; var OAuth2 = class { #context; #config; #client; #jwks; constructor(context, config, options) { this.#context = context; this.#config = config; this.#client = new OAuth2Client({ ...options, authenticationMethod: "client_secret_basic_interop", async fetch(input, init) { const request = new Request2(input, init); const response = await fetch(request); if (!response.ok) { const text = await response.text(); throw new HttpError(response.status, text); } return response; } }); const loadJwks = async () => { try { const res = await fetch(options.jwksUri); if (res.status !== 200) throw new HttpError(res.status, await res.text()); const jwks = await res.json(); return jwks.keys; } catch (cause) { this.#jwks = PLazy.from(loadJwks); throw new Error("Remote unavailable", { cause }); } }; this.#jwks = PLazy.from(loadJwks); } get #redirectUri() { const url = new URL(this.#context.handlerUrl); url.searchParams.set("auth", "login"); return url; } async authenticate(request) { try { const url = new URL(request.url); const action = url.searchParams.get("auth"); const redirectUri = this.#redirectUri; switch (action) { case AuthAction.Status: { const [ctx, err] = await outcome(this.verify(request)); if (err instanceof Response) return err; if (ctx) { return Response.json({ type: AuthResultType.Authenticated, user: ctx.user }); } const codeVerifier = await generateCodeVerifier(); const state = createId(); const redirectUrl = await this.#client.authorizationCode.getAuthorizeUri({ redirectUri: redirectUri.toString(), state, codeVerifier }); return Response.json( { type: AuthResultType.UnAuthenticated, redirect: redirectUrl }, { headers: { "set-cookie": router.cookie({ name: COOKIE_VERIFIER, value: codeVerifier, path: redirectUri.pathname, secure: redirectUri.protocol === "https:", httpOnly: true }) } } ); } case AuthAction.Login: { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) throw new HttpError(400, "Missing code or state parameter"); const cookieHeader = request.headers.get("cookie"); if (!cookieHeader) throw new HttpError(400, "Missing cookies"); const { [COOKIE_VERIFIER]: codeVerifier } = parse(cookieHeader); if (!codeVerifier) throw new HttpError(400, "Missing code verifier cookie"); const token = await this.#client.authorizationCode.getToken({ redirectUri: redirectUri.toString(), code, codeVerifier }); assert(token.refreshToken, "Missing refresh token in response"); const config = this.#config; let dashboardPath = config.dashboardFile ?? "/admin.html"; if (!dashboardPath.startsWith("/")) dashboardPath = `/${dashboardPath}`; const dashboardUrl = new URL(dashboardPath, url); return router.redirect(dashboardUrl, { headers: { "set-cookie": tokenToCookie(token, redirectUri) } }); } case AuthAction.Logout: { const cookieHeader = request.headers.get("cookie"); if (!cookieHeader) throw new HttpError(400, "Missing cookies"); const cookies = parse(cookieHeader); const accessToken = cookies[COOKIE_ACCESS_TOKEN]; const refreshToken = cookies[COOKIE_REFRESH_TOKEN]; const token = { accessToken, refreshToken, expiresAt: null }; await this.#client.revoke(token).catch(() => { }); return new Response(void 0, { status: 204, headers: { "set-cookie": clearCookies(redirectUri) } }); } default: return new Response("Bad request", { status: 400 }); } } catch (error) { if (error instanceof HttpError) return new Response(error.message, { status: error.code }); return Response.json( error instanceof Error ? error.message : "Unknown error", { status: 401 } ); } } async verify(request) { const ctx = this.#context; const cookieHeader = request.headers.get("cookie"); if (!cookieHeader) throw new MissingCredentialsError("Missing cookies"); const { [COOKIE_ACCESS_TOKEN]: accessToken, [COOKIE_REFRESH_TOKEN]: refreshToken } = parse(cookieHeader); const jwks = await this.#jwks; try { if (!accessToken) throw new MissingCredentialsError("Missing access token cookie"); const key = selectKey(jwks, accessToken); const user = await verify(accessToken, key); const expiresSoon = user.exp - Math.floor(Date.now() / 1e3) < 30; if (expiresSoon && refreshToken) throw new InvalidCredentialsError("Access token will expire soon"); return { ...ctx, user, token: accessToken }; } catch (error) { if (!refreshToken) throw error; const key = selectKey(jwks, refreshToken); const [, failed] = await outcome(verify(refreshToken, key)); if (failed) throw new InvalidCredentialsError("Invalid refresh token", { cause: [failed, error] }); const token = { accessToken, refreshToken, expiresAt: null }; const newToken = await this.#client.refreshToken(token).catch((cause) => { throw new InvalidCredentialsError("Failed to refresh token", { cause: [cause, error] }); }); await verify(newToken.accessToken, key, { clockTolerance: 30 }).catch((cause) => { throw new InvalidCredentialsError("Failed to verify user", { cause: [cause, error] }); }); throw Response.json( { type: AuthResultType.NeedsRefresh }, { status: 401, headers: { "set-cookie": tokenToCookie(newToken, this.#redirectUri) } } ); } } }; function selectKey(jwks, token) { const kid = decode(token).header.kid; if (!kid) return jwks[0]; const key = jwks.find((k) => k.kid === kid); if (!key) throw new Error(`No key found for kid: ${kid}`); return key; } function clearCookies(redirectUri) { return router.cookie( { name: COOKIE_ACCESS_TOKEN, value: "", expires: /* @__PURE__ */ new Date(0), path: "/", secure: redirectUri.protocol === "https:", httpOnly: true }, { name: COOKIE_REFRESH_TOKEN, value: "", expires: /* @__PURE__ */ new Date(0), path: redirectUri.pathname, secure: redirectUri.protocol === "https:", httpOnly: true, sameSite: "strict" } ); } function tokenToCookie(token, redirectUri) { assert(token.refreshToken, "Missing refresh token in response"); return router.cookie( { name: COOKIE_ACCESS_TOKEN, value: token.accessToken, expires: token.expiresAt ? new Date(token.expiresAt) : void 0, path: "/", secure: redirectUri.protocol === "https:", httpOnly: true }, { name: COOKIE_REFRESH_TOKEN, value: token.refreshToken, expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3), // 30 days // TODO: make sure we can create a separate path to do refreshes path: redirectUri.pathname, secure: redirectUri.protocol === "https:", httpOnly: true, sameSite: "strict" } ); } export { OAuth2 };