Skip to content

Commit

Permalink
feat: oauth + authentik support (#372)
Browse files Browse the repository at this point in the history
  • Loading branch information
diced committed Jul 21, 2023
1 parent 485a1ae commit 8b74b0b
Show file tree
Hide file tree
Showing 20 changed files with 746 additions and 40 deletions.
11 changes: 5 additions & 6 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/components/pages/settings/parts/SettingsAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
36 changes: 32 additions & 4 deletions src/components/pages/settings/parts/SettingsOAuth.tsx
Original file line number Diff line number Diff line change
@@ -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 />,
Expand All @@ -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],
Expand All @@ -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>
);
Expand All @@ -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 ?? []);
Expand Down
2 changes: 1 addition & 1 deletion src/components/pages/users/EditUserModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/components/pages/users/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 2 additions & 0 deletions src/lib/api/response.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/lib/readToDataURL.ts → src/lib/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
8 changes: 7 additions & 1 deletion src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down
21 changes: 21 additions & 0 deletions src/lib/login.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 18 additions & 4 deletions src/lib/middleware/ziplineAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
70 changes: 70 additions & 0 deletions src/lib/oauth/providerUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
};
Loading

0 comments on commit 8b74b0b

Please sign in to comment.