feature(auth): replace clerk.com with custom auth (#103)
* feature(auth): replace clerk.com with custom auth * minor fixes * remove notification preferences * decrease live events interval fix(api): cookies.. # Conflicts: # .gitignore # apps/api/src/index.ts # apps/dashboard/src/app/providers.tsx # packages/trpc/src/trpc.ts
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
f28802b1c2
commit
d31d9924a5
19
packages/auth/constants.ts
Normal file
19
packages/auth/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Sorry co.uk, but you're not a top domain
|
||||
const parseCookieDomain = (url: string) => {
|
||||
const domain = new URL(url);
|
||||
return {
|
||||
domain: domain.hostname.split('.').slice(-2).join('.'),
|
||||
secure: domain.protocol === 'https:',
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = parseCookieDomain(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? '');
|
||||
|
||||
export const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
|
||||
export const COOKIE_OPTIONS = {
|
||||
domain: parsed.domain,
|
||||
secure: parsed.secure,
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
} as const;
|
||||
2
packages/auth/index.ts
Normal file
2
packages/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './src';
|
||||
export * from './constants';
|
||||
10
packages/auth/nextjs.ts
Normal file
10
packages/auth/nextjs.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { unstable_cache } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { validateSessionToken } from './src/session';
|
||||
|
||||
export const auth = async () => {
|
||||
const token = (await cookies().get('session')?.value) ?? null;
|
||||
return cachedAuth(token);
|
||||
};
|
||||
|
||||
export const cachedAuth = unstable_cache(validateSessionToken);
|
||||
27
packages/auth/package.json
Normal file
27
packages/auth/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@openpanel/auth",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@openpanel/db": "workspace:^",
|
||||
"@openpanel/validation": "workspace:^",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"arctic": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/react": "^18.2.0",
|
||||
"prisma": "^5.1.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "14.2.1",
|
||||
"react": "18.2.0"
|
||||
}
|
||||
}
|
||||
18
packages/auth/server/oauth.ts
Normal file
18
packages/auth/server/oauth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { GitHub } from 'arctic';
|
||||
|
||||
export type { OAuth2Tokens } from 'arctic';
|
||||
import * as Arctic from 'arctic';
|
||||
|
||||
export { Arctic };
|
||||
|
||||
export const github = new GitHub(
|
||||
process.env.GITHUB_CLIENT_ID ?? '',
|
||||
process.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
process.env.GITHUB_REDIRECT_URI ?? '',
|
||||
);
|
||||
|
||||
export const google = new Arctic.Google(
|
||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
||||
);
|
||||
20
packages/auth/src/cookie.ts
Normal file
20
packages/auth/src/cookie.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { ISetCookie } from '@openpanel/validation';
|
||||
import { COOKIE_OPTIONS } from '../constants';
|
||||
|
||||
export function setSessionTokenCookie(
|
||||
setCookie: ISetCookie,
|
||||
token: string,
|
||||
expiresAt: Date,
|
||||
): void {
|
||||
setCookie('session', token, {
|
||||
maxAge: expiresAt.getTime() - new Date().getTime(),
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(setCookie: ISetCookie): void {
|
||||
setCookie('session', '', {
|
||||
maxAge: 0,
|
||||
...COOKIE_OPTIONS,
|
||||
});
|
||||
}
|
||||
4
packages/auth/src/index.ts
Normal file
4
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './cookie';
|
||||
export * from './oauth';
|
||||
export * from './password';
|
||||
export * from './session';
|
||||
18
packages/auth/src/oauth.ts
Normal file
18
packages/auth/src/oauth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { GitHub } from 'arctic';
|
||||
|
||||
export type { OAuth2Tokens } from 'arctic';
|
||||
import * as Arctic from 'arctic';
|
||||
|
||||
export { Arctic };
|
||||
|
||||
export const github = new GitHub(
|
||||
process.env.GITHUB_CLIENT_ID ?? '',
|
||||
process.env.GITHUB_CLIENT_SECRET ?? '',
|
||||
process.env.GITHUB_REDIRECT_URI ?? '',
|
||||
);
|
||||
|
||||
export const google = new Arctic.Google(
|
||||
process.env.GOOGLE_CLIENT_ID ?? '',
|
||||
process.env.GOOGLE_CLIENT_SECRET ?? '',
|
||||
process.env.GOOGLE_REDIRECT_URI ?? '',
|
||||
);
|
||||
41
packages/auth/src/password.ts
Normal file
41
packages/auth/src/password.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { sha1 } from '@oslojs/crypto/sha1';
|
||||
import { encodeHexLowerCase } from '@oslojs/encoding';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await hash(password, {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPasswordHash(
|
||||
hash: string,
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
return await verify(hash, password);
|
||||
}
|
||||
|
||||
export async function verifyPasswordStrength(
|
||||
password: string,
|
||||
): Promise<boolean> {
|
||||
if (password.length < 8 || password.length > 255) {
|
||||
return false;
|
||||
}
|
||||
const hash = encodeHexLowerCase(sha1(new TextEncoder().encode(password)));
|
||||
const hashPrefix = hash.slice(0, 5);
|
||||
const response = await fetch(
|
||||
`https://api.pwnedpasswords.com/range/${hashPrefix}`,
|
||||
);
|
||||
const data = await response.text();
|
||||
const items = data.split('\n');
|
||||
for (const item of items) {
|
||||
const hashSuffix = item.slice(0, 35).toLowerCase();
|
||||
if (hash === hashPrefix + hashSuffix) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
83
packages/auth/src/session.ts
Normal file
83
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { type Session, type User, db } from '@openpanel/db';
|
||||
import { sha256 } from '@oslojs/crypto/sha2';
|
||||
import {
|
||||
encodeBase32LowerCaseNoPadding,
|
||||
encodeHexLowerCase,
|
||||
} from '@oslojs/encoding';
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
const bytes = new Uint8Array(20);
|
||||
crypto.getRandomValues(bytes);
|
||||
const token = encodeBase32LowerCaseNoPadding(bytes);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
token: string,
|
||||
userId: string,
|
||||
): Promise<Session> {
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await db.session.create({
|
||||
data: session,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export const EMPTY_SESSION: SessionValidationResult = {
|
||||
session: null,
|
||||
user: null,
|
||||
userId: null,
|
||||
};
|
||||
|
||||
export async function validateSessionToken(
|
||||
token: string | null,
|
||||
): Promise<SessionValidationResult> {
|
||||
if (!token) {
|
||||
return EMPTY_SESSION;
|
||||
}
|
||||
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
|
||||
const result = await db.session.findUnique({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
if (result === null) {
|
||||
return EMPTY_SESSION;
|
||||
}
|
||||
const { user, ...session } = result;
|
||||
if (Date.now() >= session.expiresAt.getTime()) {
|
||||
await db.session.delete({ where: { id: sessionId } });
|
||||
return EMPTY_SESSION;
|
||||
}
|
||||
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
|
||||
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
||||
await db.session.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
expiresAt: session.expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { session, user, userId: user.id };
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||
await db.session.delete({ where: { id: sessionId } });
|
||||
}
|
||||
|
||||
export type SessionValidationResult =
|
||||
| { session: Session; user: User; userId: string }
|
||||
| { session: null; user: null; userId: null };
|
||||
12
packages/auth/tsconfig.json
Normal file
12
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user