This commit is contained in:
Mythie
2025-01-02 15:33:37 +11:00
committed by David Nguyen
parent 9183f668d3
commit f7a98180d7
413 changed files with 29538 additions and 1606 deletions

View File

@ -0,0 +1,72 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { emailPasswordRoute } from './routes/email-password';
import { googleRoute } from './routes/google';
import { passkeyRoute } from './routes/passkey';
import { sessionRoute } from './routes/session';
import { signOutRoute } from './routes/sign-out';
import type { HonoAuthContext } from './types/context';
// Note: You must chain routes for Hono RPC client to work.
export const auth = new Hono<HonoAuthContext>()
.use(async (c, next) => {
c.set('requestMetadata', extractRequestMetadata(c.req.raw));
await next();
})
.route('/', sessionRoute)
.route('/', signOutRoute)
.route('/email-password', emailPasswordRoute)
.route('/passkey', passkeyRoute)
.route('/google', googleRoute);
/**
* Handle errors.
*/
auth.onError((err, c) => {
console.error(`-----------`);
console.error(`-----------`);
console.error(`-----------`);
console.error(`${err}`);
if (err instanceof HTTPException) {
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: err.message,
statusCode: err.status,
},
err.status,
);
}
if (err instanceof AppError) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const statusCode = (err.statusCode || 500) as ContentfulStatusCode;
return c.json(
{
code: err.code,
message: err.message,
statusCode: err.statusCode,
},
statusCode,
);
}
// Handle other errors
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: 'Internal Server Error',
statusCode: 500,
},
500,
);
});
export type AuthAppType = typeof auth;

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,34 @@
import type { Context } from 'hono';
import { getSignedCookie, setSignedCookie } from 'hono/cookie';
import { authDebugger } from '../utils/debugger';
/**
* Get the session cookie attached to the request headers.
*
* @param c - The Hono context.
*/
export const getSessionCookie = async (c: Context) => {
const sessionId = await getSignedCookie(c, 'secret', 'sessionId');
return sessionId;
};
/**
* 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, 'sessionId', sessionToken, 'secret', {
path: '/',
// sameSite: '', // whats the default? we need to change this for embed right?
// secure: true,
domain: 'localhost', // todo
}).catch((err) => {
authDebugger(`Error setting signed cookie: ${err}`);
throw err;
});
};

View File

@ -0,0 +1,107 @@
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import type { Session, User } from '@prisma/client';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
export type SessionValidationResult =
| {
session: Session;
user: Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' // Todo
>;
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, // todo
userId,
updatedAt: new Date(),
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
ipAddress: metadata.ipAddress ?? null,
userAgent: metadata.userAgent ?? null,
};
await prisma.session.create({
data: session,
});
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: true,
},
});
// user: {
// select: {
// id: true,
// name: true,
// email: true,
// emailVerified: true,
// avatarImageId: true,
// twoFactorEnabled: true,
// },
// },
// todo; how can result.user be null?
if (result === null || !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): Promise<void> => {
await prisma.session.delete({ where: { id: sessionId } });
};

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,5 @@
export const authDebugger = (message: string) => {
if (process.env.NODE_ENV === 'development') {
console.log(`[DEBUG]: ${message}`);
}
};

View File

@ -0,0 +1,57 @@
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';
import { authDebugger } from './debugger';
export const getSession = async (c: Context | Request): Promise<SessionValidationResult> => {
// Todo: Make better
const sessionId = await getSessionCookie(mapRequestToContextForCookie(c));
authDebugger(`Session ID: ${sessionId}`);
if (!sessionId) {
return {
isAuthenticated: false,
session: null,
user: null,
};
}
return await validateSessionToken(sessionId);
};
export const getRequiredSession = async (c: Context | Request) => {
const { session, user } = await getSession(mapRequestToContextForCookie(c));
if (session && user) {
return { session, user };
}
// Todo: Test if throwing errors work
if (c instanceof Request) {
throw new Error('Unauthorized');
}
throw new AppError(AuthenticationErrorCode.Unauthorized);
};
const mapRequestToContextForCookie = (c: Context | Request) => {
if (c instanceof Request) {
// c.req.raw.headers.
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,342 @@
import { zValidator } from '@hono/zod-validator';
import { compare } from '@node-rs/bcrypt';
import { Hono } from 'hono';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import {
ZForgotPasswordSchema,
ZResetPasswordSchema,
ZSignInFormSchema,
ZSignUpRequestSchema,
ZVerifyEmailSchema,
} from '../types/email-password';
export const emailPasswordRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', zValidator('json', ZSignInFormSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode } = c.req.valid('json');
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (!user || !user.password) {
throw new AppError(AuthenticationErrorCode.NotFound, {
message: 'User not found',
});
}
const isPasswordsSame = await compare(password, user.password);
if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.IncorrectTwoFactorCode);
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
}
throw new AppError('UNVERIFIED_EMAIL', {
message: 'Unverified email',
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
await onAuthorize({ userId: user.id }, c);
return c.text('', 201);
})
/**
* Signup endpoint.
*/
.post('/signup', zValidator('json', ZSignUpRequestSchema), async (c) => {
// if (NEXT_PUBLIC_DISABLE_SIGNUP() === 'true') {
// throw new AppError('SIGNUP_DISABLED', {
// message: 'Signups are disabled.',
// });
// }
const { name, email, password, signature, url } = c.req.valid('json');
// if (IS_BILLING_ENABLED() && url && url.length < 6) {
// throw new AppError(AppErrorCode.PREMIUM_PROFILE_URL, {
// message: 'Only subscribers can have a username shorter than 6 characters',
// });
// }
const user = await createUser({ name, email, password, signature, url });
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
// Todo: Check this.
return c.json({
user,
});
})
/**
* Verify email endpoint.
*/
.post('/verify-email', zValidator('json', ZVerifyEmailSchema), async (c) => {
await verifyEmail({ token: c.req.valid('json').token });
return c.text('OK', 201);
})
/**
* Forgot password endpoint.
*/
.post('/forgot-password', zValidator('json', ZForgotPasswordSchema), async (c) => {
const { email } = c.req.valid('json');
await forgotPassword({
email,
});
return c.text('OK', 201);
})
/**
* Reset password endpoint.
*/
.post('/reset-password', zValidator('json', ZResetPasswordSchema), async (c) => {
const { token, password } = c.req.valid('json');
await resetPassword({
token,
password,
});
return c.text('OK', 201);
})
/**
* Setup two factor authentication.
*/
.post('/2fa/setup', async (c) => {
const { user } = await getRequiredSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post(
'/2fa/enable',
zValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
)
/**
* Disable two factor authentication.
*/
.post(
'/2fa/disable',
zValidator(
'json',
z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.json({
success: true,
});
},
)
/**
* View backup codes.
*/
.post(
'/2fa/view-recovery-codes',
zValidator(
'json',
z.object({
token: z.string(),
}),
),
async (c) => {
const { user: sessionUser } = await getRequiredSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);

View File

@ -0,0 +1,202 @@
import { Google, decodeIdToken, generateCodeVerifier, generateState } from 'arctic';
import { Hono } from 'hono';
import { getCookie, setCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
const options = {
clientId: import.meta.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID,
clientSecret: import.meta.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
redirectUri: 'http://localhost:3000/api/auth/google/callback',
scope: ['openid', 'email', 'profile'],
id: 'google',
};
const google = new Google(options.clientId, options.clientSecret, options.redirectUri);
// todo: NEXT_PRIVATE_OIDC_WELL_KNOWN???
export const googleRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', (c) => {
const scopes = options.scope;
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
setCookie(c, 'google_oauth_state', state, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
setCookie(c, 'google_code_verifier', codeVerifier, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 10, // 10 minutes
sameSite: 'lax',
});
// return new Response(null, {
// status: 302,
// headers: {
// Location: url.toString()
// }
// });
return c.json({
redirectUrl: url,
});
})
/**
* Google callback verification.
*/
.get('/callback', async (c) => {
// Todo: Use ZValidator to validate query params.
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = getCookie(c, 'google_oauth_state');
const storedCodeVerifier = getCookie(c, 'google_code_verifier');
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
const tokens = await google.validateAuthorizationCode(code, storedCodeVerifier);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
console.log(tokens);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
console.log(claims);
const googleEmail = claims.email;
const googleName = claims.name;
const googleSub = claims.sub;
if (
typeof googleEmail !== 'string' ||
typeof googleName !== 'string' ||
typeof googleSub !== 'string'
) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid google claims',
});
}
if (claims.email_verified !== true) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: 'google',
providerAccountId: googleSub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
}
const userWithSameEmail = await prisma.user.findFirst({
where: {
email: googleEmail,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: userWithSameEmail.id,
},
});
// Todo: Link account
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: googleEmail,
name: googleName,
},
});
await tx.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: user.id,
},
});
return user;
});
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect('/documents', 302); // Todo: Redirect
})
/**
* Setup passkey authentication.
*/
.post('/setup', async (c) => {
const { user } = await getRequiredSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
});

View File

@ -0,0 +1,144 @@
import { zValidator } from '@hono/zod-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { Hono } from 'hono';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { prisma } from '@documenso/prisma';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import { ZPasskeyAuthorizeSchema } from '../types/passkey';
export const passkeyRoute = new Hono<HonoAuthContext>()
.post('/authorize', zValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
return null;
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.user;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
return null;
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
await onAuthorize({ userId: user.id }, c);
return c.json(
{
url: '/documents',
},
200,
);
})
.post('/register', async (c) => {
const { user } = await getRequiredSession(c);
//
})
.post(
'/pre-authenticate',
zValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
//
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
);

View File

@ -0,0 +1,10 @@
import { Hono } from 'hono';
import type { SessionValidationResult } from '../lib/session/session';
import { getSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono().get('/session', async (c) => {
const session: SessionValidationResult = await getSession(c);
return c.json(session);
});

View File

@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { deleteCookie, getSignedCookie } from 'hono/cookie';
import { invalidateSession, validateSessionToken } from '../lib/session/session';
export const signOutRoute = new Hono().post('/signout', async (c) => {
// todo: secret
const sessionId = await getSignedCookie(c, 'secret', 'sessionId');
if (!sessionId) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionId);
if (!session) {
return new Response('No session found', { status: 401 });
}
await invalidateSession(session.id);
deleteCookie(c, 'sessionId', {
path: '/',
secure: true,
domain: 'example.com',
});
return c.status(200);
});

View File

@ -0,0 +1,7 @@
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
export type HonoAuthContext = {
Variables: {
requestMetadata: RequestMetadata;
};
};

View File

@ -0,0 +1,69 @@
import { z } from 'zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
export const ZPasswordSchema = z
.string()
.min(8, { message: 'Must be at least 8 characters in length' })
.max(72, { message: 'Cannot be more than 72 characters in length' })
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
message: 'One uppercase character',
})
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
message: 'One lowercase character',
})
.refine((value) => value.length > 25 || /\d/.test(value), {
message: 'One number',
})
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
message: 'One special character is required',
});
export const ZSignUpRequestSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().nullish(),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export type TSignUpRequestSchema = z.infer<typeof ZSignUpRequestSchema>;
export const ZForgotPasswordSchema = z.object({
email: z.string().email().min(1),
});
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
export const ZResetPasswordSchema = z.object({
password: ZPasswordSchema,
token: z.string().min(1),
});
export type TResetPasswordSchema = z.infer<typeof ZResetPasswordSchema>;
export const ZVerifyEmailSchema = z.object({
token: z.string().min(1),
});
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;

View File

@ -0,0 +1,8 @@
import { z } from 'zod';
export const ZPasskeyAuthorizeSchema = z.object({
csrfToken: z.string().min(1),
credential: z.string().min(1),
});
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;