feat: oauth + authentik support (#372)

This commit is contained in:
diced
2023-07-21 11:47:46 -07:00
parent 485a1ae2e1
commit 8b74b0b195
20 changed files with 746 additions and 40 deletions

View File

@@ -49,16 +49,15 @@ model OAuthProvider {
userId String
provider OAuthProviderType
username String
accessToken String
refreshToken String
expiresIn Int
scope String
tokenType String
profile Json
refreshToken String?
oauthId String?
user User @relation(fields: [userId], references: [id])
@@unique([userId, provider])
@@unique([provider, oauthId])
}
enum OAuthProviderType {

View File

@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import useAvatar from '@/lib/hooks/useAvatar';
import { readToDataURL } from '@/lib/readToDataURL';
import { readToDataURL } from '@/lib/base64';
import { useUserStore } from '@/lib/store/user';
import { Avatar, Button, Card, FileInput, Group, Paper, Stack, Text, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';

View File

@@ -1,16 +1,22 @@
import { useConfig } from '@/components/ConfigProvider';
import { Response } from '@/lib/api/response';
import { fetchApi } from '@/lib/fetchApi';
import { findProvider } from '@/lib/oauth/providerUtil';
import { useSettingsStore } from '@/lib/store/settings';
import { useUserStore } from '@/lib/store/user';
import { Button, Group, Paper, Stack, Switch, Text, Title } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import type { OAuthProviderType } from '@prisma/client';
import {
IconBrandDiscordFilled,
IconBrandGithubFilled,
IconBrandGoogle,
IconCheck,
IconCircleKeyFilled,
IconUserExclamation,
} from '@tabler/icons-react';
import Link from 'next/link';
import { mutate } from 'swr';
const icons = {
DISCORD: <IconBrandDiscordFilled />,
@@ -27,8 +33,30 @@ const names = {
};
function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked: boolean }) {
const unlink = async () => {};
const unlink = async () => {
const { error } = await fetchApi<Response['/api/auth/oauth']>('/api/auth/oauth', 'DELETE', {
provider,
});
if (error) {
notifications.show({
title: 'Failed to unlink account',
message: error.message,
color: 'red',
icon: <IconUserExclamation size='1rem' />,
});
} else {
notifications.show({
title: 'Account unlinked',
message: `Your ${names[provider]} account has been unlinked.`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
mutate('/api/user');
}
};
const baseProps = {
size: 'sm',
leftIcon: icons[provider],
@@ -45,7 +73,7 @@ function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked
Unlink {names[provider]} account
</Button>
) : (
<Button {...baseProps} component={Link} href={`/api/auth/oauth/${provider.toLowerCase()}?link=true`}>
<Button {...baseProps} component={Link} href={`/api/auth/oauth/${provider.toLowerCase()}?state=link`}>
Link {names[provider]} account
</Button>
);
@@ -54,7 +82,7 @@ function OAuthButton({ provider, linked }: { provider: OAuthProviderType; linked
export default function SettingsOAuth() {
const config = useConfig();
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
const user = useUserStore((state) => state.user);
const discordLinked = findProvider('DISCORD', user?.oauthProviders ?? []);
const githubLinked = findProvider('GITHUB', user?.oauthProviders ?? []);

View File

@@ -1,7 +1,7 @@
import { Response } from '@/lib/api/response';
import { User } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { readToDataURL } from '@/lib/readToDataURL';
import { readToDataURL } from '@/lib/base64';
import { canInteract } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';
import {

View File

@@ -24,7 +24,7 @@ import { useState } from 'react';
import { mutate } from 'swr';
import UserGridView from './views/UserGridView';
import UserTableView from './views/UserTableView';
import { readToDataURL } from '@/lib/readToDataURL';
import { readToDataURL } from '@/lib/base64';
import { canInteract } from '@/lib/role';
import { useUserStore } from '@/lib/store/user';

View File

@@ -1,5 +1,6 @@
import { ApiLoginResponse } from '@/pages/api/auth/login';
import { ApiLogoutResponse } from '@/pages/api/auth/logout';
import { ApiAuthOauthResponse } from '@/pages/api/auth/oauth';
import { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
import { ApiSetupResponse } from '@/pages/api/setup';
import { ApiUploadResponse } from '@/pages/api/upload';
@@ -17,6 +18,7 @@ import { ApiUsersResponse } from '@/pages/api/users';
import { ApiUsersIdResponse } from '@/pages/api/users/[id]';
export type Response = {
'/api/auth/oauth': ApiAuthOauthResponse;
'/api/auth/login': ApiLoginResponse;
'/api/auth/logout': ApiLogoutResponse;
'/api/user/files/[id]/password': ApiUserFilesIdPasswordResponse;

View File

@@ -5,4 +5,14 @@ export async function readToDataURL(file: File): Promise<string> {
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
}
export async function fetchToDataURL(url: string) {
const res = await fetch(url);
if (!res.ok) return null;
const arr = await res.arrayBuffer();
const base64 = Buffer.from(arr).toString('base64');
return `data:${res.headers.get('content-type')};base64,${base64}`;
}

View File

@@ -42,11 +42,17 @@ export default class Logger {
return (
' ' +
Object.entries(extra)
.map(([key, value]) => `${blue(key)}${gray('=')}${JSON.stringify(value)}`)
.map(([key, value]) => `${blue(key)}${gray('=')}${JSON.stringify(value, this.replacer)}`)
.join(' ')
);
}
private replacer(key: string, value: unknown) {
if (key === 'password') return '********';
if (key === 'avatar') return '[base64]';
return value;
}
private write(message: string, level: LoggerLevel, extra?: Record<string, unknown>) {
process.stdout.write(`${this.format(message, level)}${extra ? this.formatExtra(extra) : ''}\n`);
}

21
src/lib/login.ts Normal file
View File

@@ -0,0 +1,21 @@
import { config } from './config';
import { serializeCookie } from './cookie';
import { encryptToken } from './crypto';
import { User } from './db/models/user';
import { NextApiRes } from './response';
export function loginToken(res: NextApiRes, user: User) {
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
return token;
}

View File

@@ -18,14 +18,28 @@ declare module 'next' {
}
}
export function parseUserToken(encryptedToken?: string): string {
if (!encryptedToken) throw { error: 'Unauthorized' };
export function parseUserToken(encryptedToken: string | undefined | null): string;
export function parseUserToken(encryptedToken: string | undefined | null, noThrow: true): string | null;
export function parseUserToken(
encryptedToken: string | undefined | null,
noThrow: boolean = false
): string | null {
if (!encryptedToken) {
if (noThrow) return null;
throw { error: 'no token' };
}
const decryptedToken = decryptToken(encryptedToken, config.core.secret);
if (!decryptedToken) throw { error: 'could not decrypt token' };
if (!decryptedToken) {
if (noThrow) return null;
throw { error: 'could not decrypt token' };
}
const [date, token] = decryptedToken;
if (isNaN(new Date(date).getTime())) throw { error: 'could not decrypt token date' };
if (isNaN(new Date(date).getTime())) {
if (noThrow) return null;
throw { error: 'invalid token' };
}
return token;
}

View File

@@ -7,3 +7,73 @@ export function findProvider(
): User['oauthProviders'][0] | undefined {
return providers.find((p) => p.provider === provider);
}
export const githubAuth = {
url: (clientId: string, state?: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
state ? `&state=${state}` : ''
}`,
user: async (accessToken: string) => {
const res = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const discordAuth = {
url: (clientId: string, origin: string, state?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/discord`
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
user: async (accessToken: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const googleAuth = {
url: (clientId: string, origin: string, state?: string) =>
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/google`
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
state ? `&state=${state}` : ''
}`,
user: async (accessToken: string) => {
const res = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const authentikAuth = {
url: (clientId: string, origin: string, authorizeUrl: string, state?: string) =>
`${authorizeUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/authentik`
)}&response_type=code&scope=openid+email+profile${state ? `&state=${state}` : ''}`,
user: async (accessToken: string, userInfoUrl: string) => {
const res = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};

214
src/lib/oauth/withOAuth.ts Normal file
View File

@@ -0,0 +1,214 @@
import { NextApiReq, NextApiRes } from '@/lib/response';
import { OAuthProviderType, Prisma } from '@prisma/client';
import { prisma } from '../db';
import { parseUserToken } from '../middleware/ziplineAuth';
import { findProvider } from './providerUtil';
import { createToken, encryptToken } from '../crypto';
import { serializeCookie } from '../cookie';
import { config } from '../config';
import { loginToken } from '../login';
import { User } from '../db/models/user';
import Logger, { log } from '../logger';
export interface OAuthQuery {
state?: string;
code: string;
host: string;
}
export interface OAuthResponse {
username?: string;
user_id?: string;
access_token?: string;
refresh_token?: string;
avatar?: string | null;
error?: string;
error_code?: number;
redirect?: string;
}
export const withOAuth =
(provider: OAuthProviderType, oauthProfile: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>) =>
async (req: NextApiReq, res: NextApiRes) => {
const logger = log('api').c('auth').c('oauth').c(provider.toLowerCase());
req.query.host = req.headers.host ?? 'localhost:3000';
const response = await oauthProfile(req.query as OAuthQuery, logger);
if (response.error) {
return res.serverError(response.error, {
oauth: response.error_code,
});
}
if (response.redirect) {
return res.redirect(response.redirect);
}
logger.debug('oauth response', {
response,
});
const existingOauth = await prisma.oAuthProvider.findUnique({
where: {
provider_oauthId: {
provider: provider,
oauthId: response.user_id!,
},
},
});
const existingUser = await prisma.user.findFirst({
where: {
username: response.username!,
},
select: {
id: true,
username: true,
},
});
const { state } = req.query as OAuthQuery;
let rawToken: string | undefined;
if (req.cookies.zipline_token) rawToken = req.cookies.zipline_token;
else if (req.headers.authorization) rawToken = req.headers.authorization;
const token = parseUserToken(rawToken, true);
const user = await prisma.user.findFirst({
where: {
token: token ?? '',
},
include: {
oauthProviders: true,
},
});
const userOauth = findProvider(provider, user?.oauthProviders ?? []);
if (state === 'link') {
if (!user) return res.unauthorized();
if (findProvider(provider, user.oauthProviders))
return res.badRequest('This account is already linked to this provider');
logger.debug(`attempting to link oauth account`, {
provider,
user: user.id,
});
try {
await prisma.user.update({
where: {
id: user.id,
},
data: {
oauthProviders: {
create: {
provider: provider,
accessToken: response.access_token!,
refreshToken: response.refresh_token!,
username: response.username!,
oauthId: response.user_id!,
},
},
},
});
loginToken(res, user);
logger.info(`linked oauth account`, {
provider,
user: user.id,
});
return res.redirect('/dashboard/settings');
} catch (e) {
logger.error(`failed to link oauth account`, {
provider,
user: user.id,
error: e,
});
return res.badRequest('Cant link account, already linked with this provider');
}
} else if (user && userOauth) {
await prisma.oAuthProvider.update({
where: {
id: userOauth.id,
},
data: {
accessToken: response.access_token!,
refreshToken: response.refresh_token!,
username: response.username!,
oauthId: response.user_id!,
},
});
loginToken(res, user);
return res.redirect('/dashboard');
} else if (existingOauth) {
const login = await prisma.oAuthProvider.update({
where: {
id: existingOauth.id,
},
data: {
accessToken: response.access_token!,
refreshToken: response.refresh_token!,
username: response.username!,
oauthId: response.user_id!,
},
include: {
user: true,
},
});
loginToken(res, login.user! as User);
logger.info(`logged in with oauth`, {
provider,
user: login.user!.id,
});
return res.redirect('/dashboard');
} else if (existingUser) {
return res.badRequest('This username is already taken');
}
try {
const nuser = await prisma.user.create({
data: {
username: response.username!,
token: createToken(),
oauthProviders: {
create: {
provider: provider,
accessToken: response.access_token!,
refreshToken: response.refresh_token!,
username: response.username!,
oauthId: response.user_id!,
},
},
avatar: response.avatar ?? null,
},
});
loginToken(res, nuser as User);
logger.info(`created user with oauth`, {
provider,
user: nuser.id,
});
return res.redirect('/dashboard');
} catch (e) {
if ((e as { code: string }).code === 'P2002') {
// already linked can't create, last failsafe lol
return res.badRequest('Cant create user, already linked with this provider');
} else throw e;
}
};

View File

@@ -3,6 +3,7 @@ import { serializeCookie } from '@/lib/cookie';
import { encryptToken, verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { cors } from '@/lib/middleware/cors';
import { method } from '@/lib/middleware/method';
@@ -40,17 +41,7 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>)
const valid = await verifyPassword(password, user.password);
if (!valid) return res.badRequest('Invalid password', { password: true });
const token = encryptToken(user.token!, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
const token = loginToken(res, user);
delete (user as any).token;
delete (user as any).password;

View File

@@ -0,0 +1,71 @@
import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { authentikAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
// thanks to @danejur for this https://github.com/diced/zipline/pull/372
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
const { authentik: authentikEnabled } = enabled(config);
if (!authentikEnabled)
return {
error: 'Authentik OAuth is not configured.',
error_code: 401,
};
if (!code)
return {
redirect: authentikAuth.url(
config.oauth.authentik.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
config.oauth.authentik.authorizeUrl!,
state
),
};
const body = new URLSearchParams({
client_id: config.oauth.authentik.clientId!,
client_secret: config.oauth.authentik.clientSecret!,
grant_type: 'authorization_code',
code,
redirect_uri: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/authentik`,
});
const res = await fetch(config.oauth.authentik.tokenUrl!, {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (!res.ok)
return {
error: 'Failed to fetch access token',
};
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.refresh_token) return { error: 'No refresh token in response' };
const userJson = await authentikAuth.user(json.access_token, config.oauth.authentik.userinfoUrl!);
if (!userJson) return { error: 'Failed to fetch user' };
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
username: userJson.preferred_username,
user_id: userJson.sub,
};
}
export default combine([method(['GET'])], withOAuth('AUTHENTIK', handler));

View File

@@ -0,0 +1,81 @@
import { config } from '@/lib/config';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
import enabled from '@/lib/oauth/enabled';
import { discordAuth } from '@/lib/oauth/providerUtil';
import { fetchToDataURL } from '@/lib/base64';
import Logger from '@/lib/logger';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
const { discord: discordEnabled } = enabled(config);
if (!discordEnabled)
return {
error: 'Discord OAuth is not configured.',
error_code: 401,
};
if (!code)
return {
redirect: discordAuth.url(
config.oauth.discord.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state
),
};
const body = new URLSearchParams({
client_id: config.oauth.discord.clientId!,
client_secret: config.oauth.discord.clientSecret!,
grant_type: 'authorization_code',
code,
redirect_uri: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
scope: 'identify',
});
logger.debug(`discord oauth request`, {
body: body.toString(),
});
const res = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (!res.ok)
return {
error: 'Failed to fetch access token',
};
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.refresh_token) return { error: 'No refresh token in response' };
const userJson = await discordAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
username: userJson.username,
user_id: userJson.id,
avatar: await fetchToDataURL(avatar),
};
}
export default combine([method(['GET'])], withOAuth('DISCORD', handler));

View File

@@ -0,0 +1,70 @@
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { githubAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
async function handler({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
const { github: githubEnabled } = enabled(config);
if (!githubEnabled)
return {
error: 'GitHub OAuth is not configured.',
error_code: 401,
};
if (!code)
return {
redirect: githubAuth.url(config.oauth.github.clientId!, state),
};
const body = JSON.stringify({
client_id: config.oauth.github.clientId!,
client_secret: config.oauth.github.clientSecret!,
code,
});
logger.debug(`github oauth request`, {
body,
});
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
body,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
if (!res.ok)
return {
error: 'Failed to fetch access token',
};
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
const userJson = await githubAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
username: userJson.login ?? userJson.name,
user_id: String(userJson.id),
avatar: await fetchToDataURL(userJson.avatar_url),
};
}
export default combine([method(['GET'])], withOAuth('GITHUB', handler));

View File

@@ -0,0 +1,72 @@
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import Logger from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import enabled from '@/lib/oauth/enabled';
import { googleAuth } from '@/lib/oauth/providerUtil';
import { OAuthQuery, OAuthResponse, withOAuth } from '@/lib/oauth/withOAuth';
async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
const { google: googleEnabled } = enabled(config);
if (!googleEnabled)
return {
error: 'Google OAuth is not configured.',
error_code: 401,
};
if (!code)
return {
redirect: googleAuth.url(
config.oauth.google.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state
),
};
const body = new URLSearchParams({
client_id: config.oauth.google.clientId!,
client_secret: config.oauth.google.clientSecret!,
grant_type: 'authorization_code',
code,
redirect_uri: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
access_type: 'offline',
});
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
if (!res.ok)
return {
error: 'Failed to fetch access token',
};
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.refresh_token) return { error: 'No refresh token in response' };
const userJson = await googleAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
return {
access_token: json.access_token,
refresh_token: json.refresh_token,
username: userJson.given_name,
user_id: userJson.id,
avatar: await fetchToDataURL(userJson.picture),
};
}
export default combine([method(['GET'])], withOAuth('GOOGLE', handler));

View File

@@ -0,0 +1,59 @@
import { prisma } from '@/lib/db';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
import { OAuthProvider, OAuthProviderType } from '@prisma/client';
export type ApiAuthOauthResponse = OAuthProvider[];
type Body = {
provider?: OAuthProviderType;
};
const logger = log('api').c('auth').c('oauth');
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiAuthOauthResponse>) {
if (req.method === 'DELETE') {
const { password } = (await prisma.user.findFirst({
where: {
id: req.user.id,
},
select: {
password: true,
},
}))!;
if (!req.user.oauthProviders.length) return res.badRequest('No providers to delete');
if (req.user.oauthProviders.length === 1 && !password)
return res.badRequest("You can't your last oauth provider without a password");
const { provider } = req.body;
if (!provider) return res.badRequest('Provider is required');
const providers = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
oauthProviders: {
deleteMany: [{ provider }],
},
},
include: {
oauthProviders: true,
},
});
logger.info(`${req.user.username} unlinked an oauth provider`, {
provider,
});
return res.ok(providers.oauthProviders);
} else {
return res.ok(req.user.oauthProviders);
}
}
export default combine([method(['GET', 'DELETE']), ziplineAuth()], handler);

View File

@@ -3,6 +3,7 @@ import { serializeCookie } from '@/lib/cookie';
import { createToken, encryptToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { User, userSelect } from '@/lib/db/models/user';
import { loginToken } from '@/lib/login';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
@@ -44,16 +45,7 @@ export async function handler(req: NextApiReq, res: NextApiRes<ApiUserTokenRespo
},
});
const token = encryptToken(user.token, config.core.secret);
const cookie = serializeCookie('zipline_token', token, {
// week
maxAge: 60 * 60 * 24 * 7,
expires: new Date(Date.now() + 60 * 60 * 24 * 7 * 1000),
path: '/',
sameSite: 'lax',
});
res.setHeader('Set-Cookie', cookie);
const token = loginToken(res, user);
delete (user as any).token;

View File

@@ -105,6 +105,12 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
});
}
await prisma.oAuthProvider.deleteMany({
where: {
userId: user.id,
},
});
const deletedUser = await prisma.user.delete({
where: {
id: user.id,