First Commit. Duplicate bunext-sqlite preset

This commit is contained in:
2026-10-18 16:47:11 +01:00
commit 81e4a8aecc
718 changed files with 41695 additions and 0 deletions
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import checkUserAccess from "./check-user-access";
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
function makeUserTypes(...types: string[]): BUN_SQLITE_BSQLITE_USER_TYPES[] {
return types.map((user_type) => ({
user_id: 1,
user_type,
}));
}
describe("checkUserAccess", () => {
test("fails with null, undefined or empty user_types", () => {
expect(
checkUserAccess({ user_types: null }).success,
).toBe(false);
expect(
checkUserAccess({ user_types: undefined }).success,
).toBe(false);
expect(checkUserAccess({ user_types: [] }).success).toBe(false);
});
test("super_admin always passes", () => {
expect(
checkUserAccess({
user_types: makeUserTypes("super_admin"),
includes: ["admin"],
}).success,
).toBe(true);
expect(
checkUserAccess({
user_types: makeUserTypes("super_admin", "revoked"),
excludes: ["revoked"],
}).success,
).toBe(true);
});
test("excludes take priority over includes", () => {
const res = checkUserAccess({
user_types: makeUserTypes("admin"),
includes: ["admin"],
excludes: ["admin"],
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`User Excluded`);
});
test("includes require a match", () => {
const res = checkUserAccess({
user_types: makeUserTypes("board_member"),
includes: ["admin"],
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`User not included`);
});
test("includes match with OR semantics", () => {
expect(
checkUserAccess({
user_types: makeUserTypes("president"),
includes: ["admin", "president"],
}).success,
).toBe(true);
});
test("fails when no includes or excludes are provided", () => {
const res = checkUserAccess({
user_types: makeUserTypes("admin"),
});
expect(res.success).toBe(false);
expect(res.msg).toBe(`No includes or excludes provided`);
});
});
@@ -0,0 +1,66 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
includes?: UserType[];
excludes?: UserType[];
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
};
export default function checkUserAccess({
user_types,
excludes,
includes,
}: Params): APIResponseObject {
if (!user_types?.[0]) {
return {
success: false,
msg: `No User Types Provided`,
};
}
if (user_types.find((t) => t.user_type === "super_admin")) {
return {
success: true,
};
}
const userTypeSet = new Set(user_types.map((t) => t.user_type));
if (excludes) {
for (let i = 0; i < excludes.length; i++) {
const exclude = excludes[i] as any;
if (userTypeSet.has(exclude)) {
return {
success: false,
msg: `User Excluded`,
};
}
}
}
if (includes) {
const is_user_included = includes.find((i) =>
userTypeSet.has(i as any),
);
if (!is_user_included) {
return {
success: false,
msg: `User not included`,
};
}
}
if (!includes?.[0] && !excludes?.[0]) {
return {
success: false,
msg: `No includes or excludes provided`,
};
}
return {
success: true,
};
}
@@ -0,0 +1,70 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
first_name: string;
last_name?: string;
email: string;
username: string;
};
/**
* Creates a self-signup account with the default `read_only` user type
* and returns its id.
*/
export default async function createSignupUser({
first_name,
last_name,
email,
username,
}: Params) {
const new_user_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
data: [
{
first_name,
last_name: last_name || "",
email,
username,
},
],
});
/**
* bun-sqlite inserts with `RETURNING *` by default, so the new row
* comes back as `singleRes` (with `insertId` as a fallback).
*/
const new_user_id =
new_user_res.singleRes?.id || new_user_res.postInsertReturn?.insertId;
if (!new_user_id) {
throw new AppError(`Couldn't create your account. Please try again.`);
}
const type_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
data: [
{
user_id: new_user_id,
user_type: "read_only",
},
],
});
if (!type_res.success) {
throw new AppError(`Couldn't create your account. Please try again.`);
}
return new_user_id;
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: string | number;
};
export default async function deleteUserSSOCodes({ user_id }: Params) {
return await BunSQLite.delete<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
});
}
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
type Params = {
identifier: string;
};
/**
* Looks a user up by email or username.
*/
export default async function findUserByIdentifier({ identifier }: Params) {
return (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
email: {
value: identifier,
},
username: {
value: identifier,
},
},
searchOperator: "OR",
},
})
)?.singleRes;
}
@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import generateCSRF from "./gen-csrf";
describe("generateCSRF", () => {
test("returns a non-empty string", () => {
expect(generateCSRF().length).toBeGreaterThan(0);
});
test("is unique across calls", () => {
const a = generateCSRF();
const b = generateCSRF();
expect(a).not.toBe(b);
});
test("produces url-safe base64 characters only", () => {
expect(generateCSRF()).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
+11
View File
@@ -0,0 +1,11 @@
export default function generateCSRF() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
@@ -0,0 +1,47 @@
import { randomInt } from "node:crypto";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
email: string;
};
/**
* Derives a free username from an email's local part, adding a numeric
* suffix when the plain version is taken.
*/
export default async function generateUniqueUsername({ email }: Params) {
const base = (email.split("@")[0] || "user")
.toLowerCase()
.replace(/[^a-z0-9._-]/g, "")
.slice(0, 24)
.padEnd(3, "0");
for (let attempt = 0; attempt < 6; attempt++) {
const candidate =
attempt == 0 ? base : `${base}${randomInt(1000, 100000)}`;
const existing = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
query: {
query: {
username: {
value: candidate,
},
},
},
})
)?.singleRes;
if (!existing?.id) {
return candidate;
}
}
throw new AppError(`Couldn't pick a username. Please sign up with the form.`);
}
@@ -0,0 +1,8 @@
import parseLoginMethod from "@/src/utils/parse-login-method";
/**
* Server-side source of truth for the site-wide login method.
*/
export default function grabLoginMethod() {
return parseLoginMethod({ value: process.env.AUTH_LOGIN_METHOD });
}
@@ -0,0 +1,50 @@
import type {
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import type { UserAuthReturn } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
};
export default async function grabUserAuthTypes({
user_id,
}: Params): Promise<UserAuthReturn> {
try {
const user_types =
(
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).payload || [];
if (user_types.find((ty) => ty.user_type == "revoked")) {
return {
success: false,
msg: "User Access Revoked",
};
}
return {
success: true,
user_types,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: string | number;
};
/**
* Server-only. Password hashes live in their own table so that no user
* query can ever return them.
*/
export default async function grabUserPasswordHash({ user_id }: Params) {
const record = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).singleRes;
return record?.password_hash || undefined;
}
+48
View File
@@ -0,0 +1,48 @@
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import _ from "lodash";
import userAuth from "./user-auth";
import checkUserAccess from "./check-user-access";
import grabUsers from "../db/users/grab-users";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
query,
}) => {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
};
}
try {
const is_admin = checkUserAccess({
user_types,
includes: ["admin"],
});
if (
!is_admin.success &&
(!query?.user_id || query.user_id !== user.id)
) {
return {
success: false,
msg: `Unauthorized`,
};
}
return await grabUsers({
query,
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -0,0 +1,12 @@
import { AppData } from "@/src/data/app-data";
type Params = {
password: string;
};
export default async function hashPassword({ password }: Params) {
return await Bun.password.hash(password, {
algorithm: "bcrypt",
cost: AppData["HashCost"],
});
}
@@ -0,0 +1,142 @@
import { randomInt } from "node:crypto";
import { AppData } from "@/src/data/app-data";
import { SiteData } from "@/src/data/site-data";
import sendEmail from "../email/send-email";
import resolveEmailRecipient from "../email/resolve-email-recipient";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { setCookies } from "@/src/utils/cookies";
import { encrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import type {
APIResponseObject,
BunextAPIRouteHandlerObjectReturn,
} from "@moduletrace/bunext/types";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
type Params = {
user_id: number;
email: string;
method: SSOMethod;
/**
* Carried over on resends so the pending login can't be extended
* indefinitely. Defaults to now.
*/
first_step_at?: number;
};
export default async function issueSSOLoginCode({
user_id,
email,
method,
first_step_at,
}: Params): Promise<BunextAPIRouteHandlerObjectReturn<APIResponseObject>> {
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
const record_sso = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
data: [
{
code: sso_code,
user_id,
},
],
table: "sso_login_codes",
update_on_duplicate: true,
});
if (!record_sso.success) {
return {
success: false,
msg: `Couldn't issue a login code. Please try again.`,
};
}
if (process.env.NODE_ENV != "production") {
console.log(
`SSO login code for ${email} (user #${user_id}): ${sso_code}\n` +
`Code expires in ${AppData["SSOCodeExpiryMinutes"]} minutes.`,
);
}
const is_reset = method == "reset";
const email_component = (
<div>
<div>
{is_reset
? "Use this code to reset your password"
: "Use this Code to Complete your login"}
</div>
<h1>{sso_code}</h1>
<div>
Code expires in {AppData["SSOCodeExpiryMinutes"]} minutes.
</div>
</div>
);
const recipient = resolveEmailRecipient({ to: email });
if (recipient) {
await sendEmail({
content: email_component,
to: recipient,
subject: is_reset
? `Use this code to reset your password.`
: `Use one-time-code to complete your login.`,
text: is_reset
? `Reset your ${SiteData["SiteName"]} password`
: `Complete your login to the ${SiteData["SiteName"]} portal`,
title: `${SiteData["SiteName"]} SSO`,
options: {
priority: "high",
},
})
.then((mail_res) => {
if (!mail_res.success) {
console.log(
`SSO email to user #${user_id} failed:\n${mail_res.msg}`,
);
}
})
.catch((error) => {
console.log(
`SSO email to user #${user_id} errored:\n${error.message}`,
);
});
}
const sso_auth: SSOAuth = {
user_id,
sso_code,
email,
method,
first_step_at: first_step_at || Date.now(),
};
const sso_auth_string = EJSON.stringify(sso_auth);
if (!sso_auth_string) {
throw new Error(`Couldn't Stringify SSO Auth`);
}
const encrypted_sso_auth_string = await encrypt(sso_auth_string);
return {
success: true,
bunext_api_route_res_transform_fn(res: Response) {
const new_res = res.clone();
setCookies(new_res, [
{
name: AppData["SSOAuthCookieName"],
value: encrypted_sso_auth_string,
},
]);
return new_res;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import { AppData } from "@/src/data/app-data";
import { setCookies } from "@/src/utils/cookies";
import { encrypt } from "@/src/utils/crypt";
import grabUsers from "../db/users/grab-users";
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_BSQLITE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import generateCSRF from "./gen-csrf";
import type { User } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import grabMediaURL from "../media/grab-media-url";
import logEvent, { grabRequestIP } from "../logger";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: string | number;
reauth?: boolean;
req?: Request;
};
export default async function loginUser({
user_id,
reauth,
req,
}: Params): Promise<Response> {
try {
const now = Date.now();
const target_user_res = await grabUsers({
query: { user_id },
});
const target_user = target_user_res.singleRes;
if (!target_user?.id) {
throw new AppError(
`Couldn't find your account. Please start again.`,
);
}
const target_user_types = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
query: { query: { user_id: { value: target_user.id } } },
})
).payload;
if (!target_user_types?.[0]) {
throw new AppError(`No User types found for this user.`);
}
if (target_user_types.find((ty) => ty.user_type == "revoked")) {
throw new AppError(`User access Revoked`);
}
let csrf_key = generateCSRF();
const profile_media = target_user.profile_media_id
? (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
targetId: target_user.profile_media_id,
})
).singleRes
: undefined;
const { media_url, media_thumbnail_url } = profile_media?.id
? grabMediaURL({ media: profile_media })
: {};
const new_logged_in_user: User = {
id: target_user.id,
csrf: csrf_key,
date: now,
first_name: target_user.first_name || "",
last_name: target_user.last_name || "",
email: target_user.email || "",
image: media_url,
image_thumbnail: media_thumbnail_url,
verification_status: 1,
logged_in_status: true,
roles: target_user.user_types || "none",
};
const user_payload_json = JSON.stringify(new_logged_in_user);
const encrypted_key = await encrypt(user_payload_json);
const res_obj: APIResponseObject = {
success: true,
singleRes: new_logged_in_user,
};
const res = new Response(JSON.stringify(res_obj), {
headers: {
"Content-Type": "application/json",
},
});
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
setCookies(res, [
{
name: AppData["AuthKeyCookieName"],
value: encrypted_key,
maxAge,
},
{
name: AppData["AuthCSRFCookieName"],
value: csrf_key,
maxAge,
},
]);
await deleteUserSSOCodes({ user_id: new_logged_in_user.id });
if (!reauth) {
await logEvent({
title: "User logged in",
category: "auth",
user_id: new_logged_in_user.id,
plain_text: `User #${new_logged_in_user.id} [${new_logged_in_user.first_name} ${new_logged_in_user.last_name}] logged in successfully.`,
metadata: {
user_id: new_logged_in_user.id,
reauth: false,
},
ip_address: grabRequestIP(req),
});
}
return res;
} catch (error: any) {
await logEvent({
title: "Login failed",
category: "auth",
plain_text: `Login failed for user_id ${user_id}: ${error.message}`,
metadata: {
user_id,
},
ip_address: grabRequestIP(req),
});
throw error;
}
}
@@ -0,0 +1,42 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
import hashPassword from "./hash-password";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: number;
password: string;
};
/**
* Creates or replaces the user's password. Outstanding login codes are
* revoked, since a code issued before the change would skip the new
* password.
*/
export default async function setUserPassword({ user_id, password }: Params) {
const password_hash = await hashPassword({ password });
const res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
data: [
{
user_id,
password_hash,
},
],
update_on_duplicate: true,
});
if (!res.success) {
throw new AppError(`Couldn't save your password. Please try again.`);
}
await deleteUserSSOCodes({ user_id });
}
+62
View File
@@ -0,0 +1,62 @@
import { AppData } from "@/src/data/app-data";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import { safeCompare } from "@/src/utils/safe-compare";
import grabUserAuthTypes from "./grab-user-auth-types";
import type { User, UserAuthReturn } from "@/src/types";
type Params = {
req: Request;
};
export default async function userAuth({
req,
}: Params): Promise<UserAuthReturn> {
try {
const auth_key_cookie = getCookie(req, AppData["AuthKeyCookieName"]);
const auth_csrf_cookie = getCookie(req, AppData["AuthCSRFCookieName"]);
const now = Date.now();
if (!auth_key_cookie) throw new Error(`Auth Key not found!`);
if (!auth_csrf_cookie) throw new Error(`Auth CSRF not found!`);
const decrypted_key = await decrypt(auth_key_cookie);
const decrypted_user_payload = JSON.parse(decrypted_key) as User;
if (!decrypted_user_payload?.id) throw new Error(`Invalid Key`);
if (!decrypted_user_payload.date) throw new Error(`Key has no date!`);
if (!decrypted_user_payload.csrf) throw new Error(`Key has no CSRF!`);
if (!safeCompare(decrypted_user_payload.csrf, auth_csrf_cookie)) {
throw new Error(`CSRF Mismatch`);
}
const time_elapsed = now - decrypted_user_payload.date;
const expiry_time = AppData["AuthExpiryDays"] * 24 * 60 * 60 * 1000;
if (time_elapsed > expiry_time) {
throw new Error(`Key Expired`);
}
const user_auth_types = await grabUserAuthTypes({
user_id: decrypted_user_payload.id,
});
if (!user_auth_types.success) {
return user_auth_types;
}
return {
success: true,
user: decrypted_user_payload,
user_types: user_auth_types.user_types,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}
@@ -0,0 +1,199 @@
import AppError from "@/src/utils/app-error";
type GoogleJWTHeader = {
alg?: string;
kid?: string;
typ?: string;
};
type GoogleJWKS = {
keys?: GoogleJWK[];
};
type GoogleJWK = JsonWebKey & {
kid?: string;
};
type GoogleIDTokenPayload = {
aud?: string | string[];
email?: string;
email_verified?: boolean | string;
exp?: number | string;
family_name?: string;
given_name?: string;
iss?: string;
name?: string;
picture?: string;
sub?: string;
};
type Params = {
clientId: string;
idToken: string;
};
type GoogleIDTokenUser = Required<Pick<GoogleIDTokenPayload, "email" | "sub">> &
Pick<
GoogleIDTokenPayload,
"name" | "given_name" | "family_name" | "picture"
>;
const GoogleJWKSUrl = "https://www.googleapis.com/oauth2/v3/certs";
let cachedGoogleKeys: {
expiresAt: number;
keys: GoogleJWK[];
} | null = null;
function decodeBase64Url(value: string) {
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalizedValue.length % 4;
const paddedValue =
padding === 0
? normalizedValue
: normalizedValue.padEnd(
normalizedValue.length + (4 - padding),
"=",
);
return Buffer.from(paddedValue, "base64");
}
function parseGoogleTokenPart<T>(value: string): T {
return JSON.parse(decodeBase64Url(value).toString("utf8")) as T;
}
async function getGoogleSigningKeys() {
if (cachedGoogleKeys && cachedGoogleKeys.expiresAt > Date.now()) {
return cachedGoogleKeys.keys;
}
const response = await fetch(GoogleJWKSUrl);
if (!response.ok) {
throw new Error(`Couldn't fetch Google's signing keys`);
}
const data = (await response.json()) as GoogleJWKS;
if (!data.keys?.length) {
throw new Error(`Google didn't return any signing keys`);
}
const maxAge =
Number(
response.headers.get("cache-control")?.match(/max-age=(\d+)/)?.[1],
) || 300;
cachedGoogleKeys = {
expiresAt: Date.now() + maxAge * 1000,
keys: data.keys,
};
return data.keys;
}
function hasMatchingAudience(
aud: GoogleIDTokenPayload["aud"],
clientId: string,
) {
if (Array.isArray(aud)) {
return aud.includes(clientId);
}
return aud === clientId;
}
export default async function verifyGoogleIdToken({
clientId,
idToken,
}: Params): Promise<GoogleIDTokenUser> {
const tokenParts = idToken.split(".");
if (tokenParts.length !== 3) {
throw new AppError(`Invalid Google token`);
}
const [encodedHeader, encodedPayload, encodedSignature] = tokenParts;
if (!encodedHeader) {
throw new Error(`No encodedHeader`);
}
if (!encodedPayload) {
throw new Error(`No encodedPayload`);
}
if (!encodedSignature) {
throw new Error(`No encodedSignature`);
}
const header = parseGoogleTokenPart<GoogleJWTHeader>(encodedHeader);
const payload = parseGoogleTokenPart<GoogleIDTokenPayload>(encodedPayload);
if (header.alg !== "RS256" || !header.kid) {
throw new Error(`Unsupported Google token signature`);
}
const signingKeys = await getGoogleSigningKeys();
const signingKey = signingKeys.find((key) => key.kid === header.kid);
if (!signingKey) {
throw new Error(`Unknown Google signing key`);
}
const importedKey = await crypto.subtle.importKey(
"jwk",
signingKey,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
false,
["verify"],
);
const isValidSignature = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
importedKey,
decodeBase64Url(encodedSignature),
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
);
if (!isValidSignature) {
throw new Error(`Invalid Google token signature`);
}
if (!hasMatchingAudience(payload.aud, clientId)) {
throw new AppError(`Google token was issued for a different app`);
}
if (
payload.iss !== "https://accounts.google.com" &&
payload.iss !== "accounts.google.com"
) {
throw new AppError(`Invalid Google token issuer`);
}
const expiresAt = Number(payload.exp || 0) * 1000;
if (!expiresAt || expiresAt <= Date.now()) {
throw new AppError(`Google token has expired`);
}
const emailVerified =
payload.email_verified === true || payload.email_verified === "true";
if (!payload.email || !emailVerified || !payload.sub) {
throw new AppError(`Google account email is not verified`);
}
return {
email: payload.email,
sub: payload.sub,
name: payload.name,
given_name: payload.given_name,
family_name: payload.family_name,
picture: payload.picture,
};
}
@@ -0,0 +1,105 @@
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { SSOAuth, SSOMethod } from "@/src/types";
import { getCookie } from "@/src/utils/cookies";
import { decrypt } from "@/src/utils/crypt";
import EJSON from "@/src/utils/ejson";
import { safeCompare } from "@/src/utils/safe-compare";
import rateLimit, { resetRateLimit } from "@/src/utils/rate-limit";
import BunSQLite from "@moduletrace/bun-sqlite";
import deleteUserSSOCodes from "./delete-user-sso-codes";
import AppError from "@/src/utils/app-error";
type Params = {
req: Request;
sso_code?: string | number;
/**
* The code's issuing method must match, e.g. a reset code can't
* complete a login.
*/
method: SSOMethod;
};
/**
* Checks an emailed code against the pending login in the SSO cookie.
* Returns the user id on success, throws a user-facing error otherwise.
*/
export default async function verifySSOCode({
req,
sso_code,
method,
}: Params) {
const sso_cookie = getCookie(req, AppData["SSOAuthCookieName"]);
if (!sso_cookie) {
throw new AppError(`No login in progress. Please start again.`);
}
if (!sso_code) {
throw new AppError(`Please enter your code.`);
}
const sso_auth = EJSON.parse(await decrypt(sso_cookie)) as SSOAuth;
if (!sso_auth?.user_id) {
throw new AppError(`Invalid login session. Please start again.`);
}
const user_id = sso_auth.user_id;
if (sso_auth.method != method) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`This code can't be used here. Please start again.`);
}
const attempts_key = `sso-login:${user_id}`;
const attempts = rateLimit({
key: attempts_key,
limit: AppData["SSOMaxCodeAttempts"],
window_ms: AppData["SSOCodeExpiryMinutes"] * 60 * 1000,
});
if (!attempts.allowed) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Too many attempts. Please request a new code.`);
}
const target_sso = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
query: {
query: {
user_id: {
value: user_id,
},
},
},
})
).singleRes;
if (
!target_sso?.code ||
!safeCompare(String(target_sso.code), String(sso_code))
) {
throw new AppError(`Invalid Code!`);
}
const time_elapsed = Date.now() - Number(target_sso.updated_at);
const expiry_time = AppData["SSOCodeExpiryMinutes"] * 60 * 1000;
if (time_elapsed > expiry_time) {
await deleteUserSSOCodes({ user_id });
throw new AppError(`Code Expired. Please start again.`);
}
resetRateLimit({ key: attempts_key });
return user_id;
}
@@ -0,0 +1,31 @@
import grabUserPasswordHash from "./grab-user-password-hash";
import hashPassword from "./hash-password";
type Params = {
user_id?: string | number;
password: string;
};
let dummy_hash: Promise<string> | undefined;
/**
* Checks a password against the user's stored hash. Unknown users and
* users without a password still pay for a full hash comparison, so
* response timing doesn't reveal which accounts exist.
*/
export default async function verifyUserPassword({
user_id,
password,
}: Params) {
const password_hash = user_id
? await grabUserPasswordHash({ user_id })
: undefined;
if (!password_hash) {
dummy_hash ||= hashPassword({ password: crypto.randomUUID() });
await Bun.password.verify(password, await dummy_hash);
return false;
}
return await Bun.password.verify(password, password_hash);
}