alinea
Version:
Headless git-based CMS
695 lines (689 loc) • 24.5 kB
JavaScript
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
};