feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
committed by Mythie
parent 9183f668d3
commit 75d7336763
1021 changed files with 60930 additions and 40839 deletions

View File

@ -0,0 +1,29 @@
export const AuthenticationErrorCode = {
AccountDisabled: 'ACCOUNT_DISABLED',
Unauthorized: 'UNAUTHORIZED',
InvalidCredentials: 'INVALID_CREDENTIALS',
SessionNotFound: 'SESSION_NOT_FOUND',
SessionExpired: 'SESSION_EXPIRED',
InvalidToken: 'INVALID_TOKEN',
MissingToken: 'MISSING_TOKEN',
InvalidRequest: 'INVALID_REQUEST',
UnverifiedEmail: 'UNVERIFIED_EMAIL',
NotFound: 'NOT_FOUND',
NotSetup: 'NOT_SETUP',
// InternalSeverError: 'INTERNAL_SEVER_ERROR',
// TwoFactorAlreadyEnabled: 'TWO_FACTOR_ALREADY_ENABLED',
// TwoFactorSetupRequired: 'TWO_FACTOR_SETUP_REQUIRED',
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
// MissingEncryptionKey: 'MISSING_ENCRYPTION_KEY',
// MissingBackupCode: 'MISSING_BACKUP_CODE',
} as const;
export type AuthenticationErrorCode =
// eslint-disable-next-line @typescript-eslint/ban-types
(typeof AuthenticationErrorCode)[keyof typeof AuthenticationErrorCode] | (string & {});

View File

@ -0,0 +1,105 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import {
formatSecureCookieName,
getCookieDomain,
useSecureCookies,
} from '@documenso/lib/constants/auth';
import { appLog } from '@documenso/lib/utils/debugger';
import { env } from '@documenso/lib/utils/env';
import { AUTH_SESSION_LIFETIME } from '../../config';
import { extractCookieFromHeaders } from '../utils/cookies';
import { generateSessionToken } from './session';
export const sessionCookieName = formatSecureCookieName('sessionId');
export const csrfCookieName = formatSecureCookieName('csrfToken');
const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {
throw new Error('NEXTAUTH_SECRET is not set');
}
return authSecret;
};
/**
* Generic auth session cookie options.
*/
export const sessionCookieOptions = {
httpOnly: true,
path: '/',
sameSite: useSecureCookies ? 'none' : 'lax',
secure: useSecureCookies,
domain: getCookieDomain(),
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
} as const;
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
return extractCookieFromHeaders(sessionCookieName, headers);
};
/**
* Get the session cookie attached to the request headers.
*
* @param c - The Hono context.
* @returns The session ID or null if no session cookie is found.
*/
export const getSessionCookie = async (c: Context): Promise<string | null> => {
const sessionId = await getSignedCookie(c, getAuthSecret(), sessionCookieName);
return sessionId || null;
};
/**
* Set the session cookie into the Hono context.
*
* @param c - The Hono context.
* @param sessionToken - The session token to set.
*/
export const setSessionCookie = async (c: Context, sessionToken: string) => {
await setSignedCookie(
c,
sessionCookieName,
sessionToken,
getAuthSecret(),
sessionCookieOptions,
).catch((err) => {
appLog('SetSessionCookie', `Error setting signed cookie: ${err}`);
throw err;
});
};
/**
* Set the session cookie into the Hono context.
*
* @param c - The Hono context.
* @param sessionToken - The session token to set.
*/
export const deleteSessionCookie = (c: Context) => {
deleteCookie(c, sessionCookieName, sessionCookieOptions);
};
export const getCsrfCookie = async (c: Context) => {
const csrfToken = await getSignedCookie(c, getAuthSecret(), csrfCookieName);
return csrfToken || null;
};
export const setCsrfCookie = async (c: Context) => {
const csrfToken = generateSessionToken();
await setSignedCookie(c, csrfCookieName, csrfToken, getAuthSecret(), {
...sessionCookieOptions,
// Explicity set to undefined for session lived cookie.
expires: undefined,
maxAge: undefined,
});
return csrfToken;
};

View File

@ -0,0 +1,150 @@
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { AUTH_SESSION_LIFETIME } from '../../config';
/**
* The user object to pass around the app.
*
* Do not put anything sensitive in here since it will be public.
*/
export type SessionUser = Pick<
User,
| 'id'
| 'name'
| 'email'
| 'emailVerified'
| 'avatarImageId'
| 'twoFactorEnabled'
| 'roles'
| 'signature'
| 'url'
| 'customerId'
>;
export type SessionValidationResult =
| {
session: Session;
user: SessionUser;
isAuthenticated: true;
}
| { session: null; user: null; isAuthenticated: false };
export const generateSessionToken = (): string => {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
};
export const createSession = async (
token: string,
userId: number,
metadata: RequestMetadata,
): Promise<Session> => {
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
id: hashedSessionId,
sessionToken: hashedSessionId,
userId,
updatedAt: new Date(),
createdAt: new Date(),
expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME),
ipAddress: metadata.ipAddress ?? null,
userAgent: metadata.userAgent ?? null,
};
await prisma.session.create({
data: session,
});
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
return session;
};
export const validateSessionToken = async (token: string): Promise<SessionValidationResult> => {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const result = await prisma.session.findUnique({
where: {
id: sessionId,
},
include: {
user: {
/**
* Do not expose anything sensitive here.
*/
select: {
id: true,
name: true,
email: true,
emailVerified: true,
avatarImageId: true,
twoFactorEnabled: true,
roles: true,
signature: true,
url: true,
customerId: true,
},
},
},
});
if (!result?.user) {
return { session: null, user: null, isAuthenticated: false };
}
const { user, ...session } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await prisma.session.delete({ where: { id: sessionId } });
return { session: null, user: null, isAuthenticated: false };
}
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await prisma.session.update({
where: {
id: session.id,
},
data: {
expiresAt: session.expiresAt,
},
});
}
return { session, user, isAuthenticated: true };
};
export const invalidateSession = async (
sessionId: string,
metadata: RequestMetadata,
): Promise<void> => {
const session = await prisma.session.delete({ where: { id: sessionId } });
await prisma.userSecurityAuditLog.create({
data: {
userId: session.userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: UserSecurityAuditLogType.SIGN_OUT,
},
});
};

View File

@ -0,0 +1,22 @@
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
import { createSession, generateSessionToken } from '../session/session';
import { setSessionCookie } from '../session/session-cookies';
type AuthorizeUser = {
userId: number;
};
/**
* Handles creating a session.
*/
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
const metadata = c.get('requestMetadata');
const sessionToken = generateSessionToken();
await createSession(sessionToken, user.userId, metadata);
await setSessionCookie(c, sessionToken);
};

View File

@ -0,0 +1,14 @@
/**
* Todo: Use library for cookies instead.
*/
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
const cookieHeader = headers.get('cookie') || '';
const cookiePairs = cookieHeader.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName));
if (!cookie) {
return null;
}
return cookie.split('=')[1].trim();
};

View File

@ -0,0 +1,56 @@
import type { Context } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { AuthenticationErrorCode } from '../errors/error-codes';
import type { SessionValidationResult } from '../session/session';
import { validateSessionToken } from '../session/session';
import { getSessionCookie } from '../session/session-cookies';
export const getSession = async (c: Context | Request) => {
const { session, user } = await getOptionalSession(mapRequestToContextForCookie(c));
if (session && user) {
return { session, user };
}
if (c instanceof Request) {
throw new Error('Unauthorized');
}
throw new AppError(AuthenticationErrorCode.Unauthorized);
};
export const getOptionalSession = async (
c: Context | Request,
): Promise<SessionValidationResult> => {
const sessionId = await getSessionCookie(mapRequestToContextForCookie(c));
if (!sessionId) {
return {
isAuthenticated: false,
session: null,
user: null,
};
}
return await validateSessionToken(sessionId);
};
/**
* Todo: (RR7) Rethink, this is pretty sketchy.
*/
const mapRequestToContextForCookie = (c: Context | Request) => {
if (c instanceof Request) {
const partialContext = {
req: {
raw: c,
},
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return partialContext as unknown as Context;
}
return c;
};

View File

@ -0,0 +1,86 @@
import { CodeChallengeMethod, OAuth2Client, generateCodeVerifier, generateState } from 'arctic';
import type { Context } from 'hono';
import { setCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { OAuthClientOptions } from '../../config';
import { sessionCookieOptions } from '../session/session-cookies';
import { getOpenIdConfiguration } from './open-id';
type HandleOAuthAuthorizeUrlOptions = {
/**
* Hono context.
*/
c: Context;
/**
* OAuth client options.
*/
clientOptions: OAuthClientOptions;
/**
* Optional redirect path to redirect the user somewhere on the app after authorization.
*/
redirectPath?: string;
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { authorization_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const scopes = clientOptions.scope;
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = oAuthClient.createAuthorizationURLWithPKCE(
authorization_endpoint,
state,
CodeChallengeMethod.S256,
codeVerifier,
scopes,
);
// Allow user to select account during login.
url.searchParams.append('prompt', 'login');
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
setCookie(c, `${clientOptions.id}_code_verifier`, codeVerifier, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
if (redirectPath) {
setCookie(c, `${clientOptions.id}_redirect_path`, `${state} ${redirectPath}`, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
}
return c.json({
redirectUrl: url.toString(),
});
};

View File

@ -0,0 +1,195 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { nanoid } from 'nanoid';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { prisma } from '@documenso/prisma';
import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { getOpenIdConfiguration } from './open-id';
type HandleOAuthCallbackUrlOptions = {
c: Context;
clientOptions: OAuthClientOptions;
};
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const requestMeta = c.get('requestMetadata');
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/documents';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid claims',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(redirectPath, 302);
}
const userWithSameEmail = await prisma.user.findFirst({
where: {
email: email,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: userWithSameEmail.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: userWithSameEmail.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in since we cannot confirm the password was set by the user.
if (!userWithSameEmail.emailVerified) {
await tx.user.update({
where: {
id: userWithSameEmail.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
});
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect(redirectPath, 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: email,
name: name,
emailVerified: new Date(),
url: nanoid(17),
},
});
await tx.account.create({
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: user.id,
},
});
return user;
});
await onCreateUserHook(createdUser).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect(redirectPath, 302);
};

View File

@ -0,0 +1,44 @@
import { z } from 'zod';
const ZOpenIdConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
scopes_supported: z.array(z.string()).optional(),
});
type OpenIdConfiguration = z.infer<typeof ZOpenIdConfigurationSchema>;
type GetOpenIdConfigurationOptions = {
requiredScopes?: string[];
};
export const getOpenIdConfiguration = async (
wellKnownUrl: string,
options: GetOpenIdConfigurationOptions = {},
): Promise<OpenIdConfiguration> => {
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC configuration: ${response.statusText}`);
}
const rawConfig = await response.json();
const config = ZOpenIdConfigurationSchema.parse(rawConfig);
// Validate required endpoints
if (!config.authorization_endpoint) {
throw new Error('Missing authorization_endpoint in OIDC configuration');
}
const supportedScopes = config.scopes_supported ?? [];
const requiredScopes = options.requiredScopes ?? [];
const unsupportedScopes = requiredScopes.filter((scope) => !supportedScopes.includes(scope));
if (unsupportedScopes.length > 0) {
throw new Error(`Requested scopes not supported by provider: ${unsupportedScopes.join(', ')}`);
}
return config;
};

View File

@ -0,0 +1,28 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* Handle an optional redirect path.
*/
export const handleRequestRedirect = (redirectUrl?: string) => {
if (!redirectUrl) {
return;
}
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl: string = '/documents') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
} else {
window.location.href = redirectUrl;
}
};