export const SUPPORTED_SCOPES = [
"read:user",
"read:repo",
"write:repo",
"read:org",
"write:org",
"read:issue",
"write:issue",
"read:pr",
"write:pr",
] as const;
export type OauthScope = (typeof SUPPORTED_SCOPES)[number];
export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
function randomHex(byteLen: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
let s = "";
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
return s;
}
export function generateClientId(): string {
return "glc_app_" + randomHex(12);
}
export function generateClientSecret(): string {
return "glcs_" + randomHex(32);
}
export function generateAuthCode(): string {
return "glca_" + randomHex(24);
}
export function generateAccessToken(): string {
return "glct_" + randomHex(32);
}
export function generateRefreshToken(): string {
return "glcr_" + randomHex(32);
}
export async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest("SHA-256", data);
const bytes = new Uint8Array(digest);
let s = "";
for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
return s;
}
export function b64urlFromBytes(bytes: Uint8Array): string {
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export async function verifyPkce(opts: {
challenge: string | null | undefined;
method: string | null | undefined;
verifier: string;
}): Promise<boolean> {
const challenge = (opts.challenge || "").trim();
if (!challenge) return false;
const method = (opts.method || "plain").toLowerCase();
if (method === "s256") {
const data = new TextEncoder().encode(opts.verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const produced = b64urlFromBytes(new Uint8Array(digest));
return timingSafeEqual(produced, challenge);
}
if (method === "plain") {
return timingSafeEqual(opts.verifier, challenge);
}
return false;
}
export function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function parseScopes(input: string | null | undefined): OauthScope[] {
if (!input) return [];
const parts = input.split(/[\s,]+/).filter(Boolean);
const seen = new Set<string>();
const out: OauthScope[] = [];
for (const p of parts) {
const s = p.trim().toLowerCase();
if (!s || seen.has(s)) continue;
if ((SUPPORTED_SCOPES as readonly string[]).includes(s)) {
out.push(s as OauthScope);
seen.add(s);
}
}
return out;
}
export function serializeScopes(scopes: readonly OauthScope[]): string {
return scopes.join(" ");
}
export function parseRedirectUris(stored: string): string[] {
return stored
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
export function isValidRedirectUri(uri: string): boolean {
try {
const u = new URL(uri);
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
if (u.protocol === "http:") {
if (!["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
return false;
}
}
if (u.hash) return false;
if (uri.includes("*")) return false;
return true;
} catch {
return false;
}
}
export function redirectUriAllowed(
candidate: string,
registered: readonly string[]
): boolean {
if (!candidate) return false;
for (const r of registered) {
if (timingSafeEqual(candidate, r)) return true;
}
return false;
}
export const __test = {
randomHex,
};
|