Files
yaak-mountain-loop/plugins/auth-oauth2/src/util.ts
T

113 lines
3.4 KiB
TypeScript

import jwt from "jsonwebtoken";
import type { AccessToken } from "./store";
export function isTokenExpired(
token: AccessToken,
tokenName: "access_token" | "id_token" = "access_token",
) {
// Fall back to the JWT's own exp claim for tokens stored without an expiry
// (eg. from a token response that had no expires_in). Decode the same token
// that gets sent as the credential.
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
return expiresAt != null && Date.now() > expiresAt;
}
/**
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
* is not a JWT or has no `exp`.
*/
export function jwtExpiresAt(token: string | undefined): number | null {
if (!token) return null;
try {
const payload = jwt.decode(token);
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
// Opaque (non-JWT) token
}
return null;
}
export function extractCode(urlStr: string, redirectUri: string | null): string | null {
const url = new URL(urlStr);
if (!urlMatchesRedirect(url, redirectUri)) {
console.log("[oauth2] URL does not match redirect origin/path; skipping.");
return null;
}
// Prefer query param; fall back to fragment if query lacks it
const query = url.searchParams;
const queryError = query.get("error");
const queryDesc = query.get("error_description");
const queryUri = query.get("error_uri");
let hashParams: URLSearchParams | null = null;
if (url.hash && url.hash.length > 1) {
hashParams = new URLSearchParams(url.hash.slice(1));
}
const hashError = hashParams?.get("error");
const hashDesc = hashParams?.get("error_description");
const hashUri = hashParams?.get("error_uri");
const error = queryError || hashError;
if (error) {
const desc = queryDesc || hashDesc;
const uri = queryUri || hashUri;
let message = `Failed to authorize: ${error}`;
if (desc) message += ` (${desc})`;
if (uri) message += ` [${uri}]`;
throw new Error(message);
}
const queryCode = query.get("code");
if (queryCode) return queryCode;
const hashCode = hashParams?.get("code");
if (hashCode) return hashCode;
console.log("[oauth2] Code not found");
return null;
}
export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolean {
if (!redirectUrl) return true;
let redirect: URL;
try {
redirect = new URL(redirectUrl);
} catch {
console.log("[oauth2] Invalid redirect URI; skipping.");
return false;
}
const sameProtocol = url.protocol === redirect.protocol;
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
const normalizePort = (u: URL) =>
(u.protocol === "https:" && (!u.port || u.port === "443")) ||
(u.protocol === "http:" && (!u.port || u.port === "80"))
? ""
: u.port;
const samePort = normalizePort(url) === normalizePort(redirect);
const normPath = (p: string) => {
const withLeading = p.startsWith("/") ? p : `/${p}`;
// strip trailing slashes, keep root as "/"
return withLeading.replace(/\/+$/g, "") || "/";
};
// Require redirect path to be a prefix of the navigated URL path
const urlPath = normPath(url.pathname);
const redirectPath = normPath(redirect.pathname);
const pathMatches = urlPath === redirectPath || urlPath.startsWith(`${redirectPath}/`);
return sameProtocol && sameHost && samePort && pathMatches;
}