@indiekit/indiekit
Version:
The little server that connects your website to the independent web
247 lines (213 loc) • 7.77 kB
JavaScript
import { createHash, randomBytes } from "node:crypto";
import process from "node:process";
import { IndiekitError } from "@indiekit/error";
import { getCanonicalUrl, randomString } from "@indiekit/util";
import { generateState, validateState } from "./state.js";
import {
findBearerToken,
introspectToken,
verifyTokenValues,
} from "./token.js";
export const IndieAuth = class {
constructor(options = {}) {
this.codeVerifier = randomString(100);
this.devMode = options.devMode;
this.iv = randomBytes(16);
this.me = getCanonicalUrl(options.me);
}
/**
* Get authentication URL
* @param {string} authorizationEndpoint - Authorization endpoint
* @param {string} state - State
* @returns {Promise<string>} Authentication URL
*/
async getAuthUrl(authorizationEndpoint, state) {
// PKCE code challenge
const base64Digest = createHash("sha256")
.update(this.codeVerifier)
.digest("base64url");
const codeChallenge = base64Digest.toString();
const authUrl = new URL(authorizationEndpoint);
authUrl.searchParams.append("client_id", this.clientId);
authUrl.searchParams.append("code_challenge_method", "S256");
authUrl.searchParams.append("code_challenge", codeChallenge);
authUrl.searchParams.append("me", this.me);
authUrl.searchParams.append("redirect_uri", this.redirectUri);
authUrl.searchParams.append("response_type", "code");
authUrl.searchParams.append("scope", "create update delete media");
authUrl.searchParams.append("state", state);
return authUrl.href;
}
/**
* Exchange authorization code for access token
* @param {string} tokenEndpoint - Token endpoint
* @param {string} code - Code received from authentication endpoint
* @returns {Promise<object>} Access token
*/
async authorizationCodeGrant(tokenEndpoint, code) {
const tokenUrl = new URL(tokenEndpoint);
tokenUrl.searchParams.append("client_id", this.clientId);
tokenUrl.searchParams.append("code", code);
tokenUrl.searchParams.append("code_verifier", this.codeVerifier);
tokenUrl.searchParams.append("grant_type", "authorization_code");
tokenUrl.searchParams.append("redirect_uri", this.redirectUri);
const tokenResponse = await fetch(tokenUrl.href, {
method: "POST",
headers: {
accept: "application/json",
},
});
if (!tokenResponse.ok) {
throw await IndiekitError.fromFetch(tokenResponse);
}
return tokenResponse.json();
}
/**
* Redirect user to IndieAuth login
* @returns {object} HTTP response
*/
login() {
return async (request, response) => {
try {
const { application } = request.app.locals;
const applicationUrl = getCanonicalUrl(application.url);
const { href: clientId } = new URL("id", applicationUrl);
const { href: callbackUrl } = new URL("session/auth", applicationUrl);
const { redirect } = request.query;
this.redirectUri = redirect
? `${callbackUrl}?redirect=${redirect}`
: `${callbackUrl}`;
this.clientId = clientId;
const state = generateState(this.clientId, this.iv);
const authUrl = await this.getAuthUrl(
application.authorizationEndpoint,
state,
);
return response.redirect(authUrl);
} catch (error) {
return response.status(401).render("session/login", {
title: response.locals.__("session.login.title"),
error,
});
}
};
}
/**
* Authorize user (i.e. check they are allowed access) by verifying
* authorization code with token endpoint (which grants an access token).
* @returns {object} HTTP response
*/
authorize() {
return async (request, response) => {
try {
const { application } = request.app.locals;
const { code, redirect, state } = request.query;
// Check redirect is to a local path
if (redirect) {
const validRedirect = redirect.match(/^\/[\w&/=?]*$/);
if (!validRedirect) {
throw IndiekitError.forbidden(
response.locals.__("ForbiddenError.invalidRedirect"),
);
}
}
if (!code) {
throw IndiekitError.badRequest(
response.locals.__("BadRequestError.missingParameter", "code"),
);
}
if (!state) {
throw IndiekitError.badRequest(
response.locals.__("BadRequestError.missingParameter", "state"),
);
}
// Check for state mismatch
if (!validateState(state, this.clientId, this.iv)) {
throw IndiekitError.forbidden(
response.locals.__("ForbiddenError.invalidState"),
);
}
// Request access token
const authorizedToken = await this.authorizationCodeGrant(
application.tokenEndpoint,
code,
);
// Check that access token is valid
if (!authorizedToken.access_token) {
throw IndiekitError.unauthorized(
response.locals.__("UnauthorizedError.invalidToken"),
);
}
// Set session token values
request.session.access_token = authorizedToken.access_token;
if (authorizedToken.scope) {
request.session.scope = authorizedToken.scope;
}
// Redirect to requested resource
return response.redirect(redirect || "/");
} catch (error) {
response.status(error.status || 500);
return response.render("session/login", {
title: response.locals.__("session.login.title"),
error,
});
}
};
}
/**
* Authenticate user (i.e. check they are who they say they are) by
* checking bearer token matches access token saved in current session.
* @returns {import("express").RequestHandler} Next middleware
*/
authenticate() {
const { devMode, me } = this;
return async function (request, response, next) {
if (devMode) {
request.session.access_token = process.env.NODE_ENV;
request.session.scope = "create update delete media";
} else if (!process.env.PASSWORD_SECRET) {
return response.redirect("/auth/new-password");
}
// If have session has access token and scope, go to next middleware
const { access_token, scope } = request.session;
if (access_token && scope) {
return next();
}
// Introspect token sent in request
try {
const { application } = request.app.locals;
const bearerToken = findBearerToken(request);
const tokenValues = await introspectToken(
application.introspectionEndpoint,
bearerToken,
);
// Check token is active and contains a `me` value
if (!tokenValues.active || !tokenValues.me) {
throw IndiekitError.unauthorized(
response.locals.__("UnauthorizedError.invalidToken"),
);
}
// Check that `me` in token values matches publication `me`
const verifiedTokenValues = verifyTokenValues(me, tokenValues);
if (!verifiedTokenValues) {
throw IndiekitError.forbidden(
response.locals.__("ForbiddenError.invalidMe"),
);
}
// Set session token values
request.session.access_token = bearerToken;
if (verifiedTokenValues.scope) {
request.session.scope = verifiedTokenValues.scope;
}
next();
} catch (error) {
if (request.method === "GET") {
return response.redirect(
`/session/login?redirect=${request.originalUrl}`,
);
}
next(error);
}
};
}
};