mirror of
https://github.com/diced/zipline.git
synced 2025-12-05 20:40:12 -08:00
feat: oauth + authentik support (#372)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
21
src/lib/login.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
214
src/lib/oauth/withOAuth.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
71
src/pages/api/auth/oauth/authentik.ts
Normal file
71
src/pages/api/auth/oauth/authentik.ts
Normal 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));
|
||||
81
src/pages/api/auth/oauth/discord.ts
Normal file
81
src/pages/api/auth/oauth/discord.ts
Normal 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));
|
||||
70
src/pages/api/auth/oauth/github.ts
Normal file
70
src/pages/api/auth/oauth/github.ts
Normal 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));
|
||||
72
src/pages/api/auth/oauth/google.ts
Normal file
72
src/pages/api/auth/oauth/google.ts
Normal 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));
|
||||
59
src/pages/api/auth/oauth/index.ts
Normal file
59
src/pages/api/auth/oauth/index.ts
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user