diff --git a/prisma/migrations/20230926013510_oauth_add_generic/migration.sql b/prisma/migrations/20230926013510_oauth_add_generic/migration.sql new file mode 100644 index 000000000..295db9bd1 --- /dev/null +++ b/prisma/migrations/20230926013510_oauth_add_generic/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OauthProviders" ADD VALUE 'OIDC'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed47f0036..e00d85a55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,6 +138,7 @@ enum OauthProviders { DISCORD GITHUB GOOGLE + OIDC } model IncompleteFile { diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 35b41a5a6..aefea02c9 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -37,6 +37,7 @@ import { IconGraph, IconHome, IconLink, + IconLogin, IconLogout, IconReload, IconSettings, @@ -137,6 +138,7 @@ export default function Layout({ children, props }) { GitHub: IconBrandGithubFilled, Discord: IconBrandDiscordFilled, Google: IconBrandGoogle, + OIDC: IconLogin, }; for (const provider of oauth_providers) { diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index 03d41d8eb..8ce83bdf0 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -138,6 +138,16 @@ export interface ConfigOAuth { google_client_id?: string; google_client_secret?: string; + + oidc_client_id?: string; + oidc_client_secret?: string; + oidc_authorize_url?: string; + oidc_token_url?: string; + oidc_userinfo_url?: string; + oidc_scopes?: string; + oidc_name_field?: string; + oidc_user_id_field?: string; + oidc_provider_display_name?: string; } export interface ConfigChunks { diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index 0f087048e..deea09d90 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -150,6 +150,16 @@ export default function readConfig() { map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'), map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'), + map('OAUTH_OIDC_CLIENT_ID', 'string', 'oauth.oidc_client_id'), + map('OAUTH_OIDC_CLIENT_SECRET', 'string', 'oauth.oidc_client_secret'), + map('OAUTH_OIDC_AUTHORIZE_URL', 'string', 'oauth.oidc_authorize_url'), + map('OAUTH_OIDC_TOKEN_URL', 'string', 'oauth.oidc_token_url'), + map('OAUTH_OIDC_USERINFO_URL', 'string', 'oauth.oidc_userinfo_url'), + map('OAUTH_OIDC_SCOPES', 'string', 'oauth.oidc_scopes'), + map('OAUTH_OIDC_NAME_FIELD', 'string', 'oauth.oidc_name_field'), + map('OAUTH_OIDC_USER_ID_FIELD', 'string', 'oauth.oidc_user_id_field'), + map('OAUTH_OIDC_PROVIDER_DISPLAY_NAME', 'string', 'oauth.oidc_provider_display_name'), + map('FEATURES_INVITES', 'boolean', 'features.invites'), map('FEATURES_INVITES_LENGTH', 'number', 'features.invites_length'), diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index 2938e4d72..f00261b1c 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -179,6 +179,16 @@ const validator = s.object({ google_client_id: s.string.nullable.default(null), google_client_secret: s.string.nullable.default(null), + + oidc_client_id: s.string.nullable.default(null), + oidc_client_secret: s.string.nullable.default(null), + oidc_authorize_url: s.string.nullable.default(null), + oidc_token_url: s.string.nullable.default(null), + oidc_userinfo_url: s.string.nullable.default(null), + oidc_scopes: s.string.nullable.default(null), + oidc_name_field: s.string.nullable.default(null), + oidc_user_id_field: s.string.nullable.default(null), + oidc_provider_display_name: s.string.nullable.default(null), }) .nullish.default(null), features: s diff --git a/src/lib/middleware/getServerSideProps.ts b/src/lib/middleware/getServerSideProps.ts index 355c94b95..b9f02cc8f 100644 --- a/src/lib/middleware/getServerSideProps.ts +++ b/src/lib/middleware/getServerSideProps.ts @@ -37,6 +37,17 @@ export const getServerSideProps: GetServerSideProps = async (ct isNotNullOrUndefined(config.oauth?.google_client_id) && isNotNullOrUndefined(config.oauth?.google_client_secret); + const oidcEnabled = + isNotNullOrUndefined(config.oauth.oidc_client_id) && + isNotNullOrUndefined(config.oauth.oidc_client_secret) && + isNotNullOrUndefined(config.oauth.oidc_authorize_url) && + isNotNullOrUndefined(config.oauth.oidc_token_url) && + isNotNullOrUndefined(config.oauth.oidc_userinfo_url) && + isNotNullOrUndefined(config.oauth.oidc_scopes) && + isNotNullOrUndefined(config.oauth.oidc_name_field) && + isNotNullOrUndefined(config.oauth.oidc_user_id_field) && + isNotNullOrUndefined(config.oauth.oidc_provider_display_name); + const oauth_providers: OauthProvider[] = []; if (ghEnabled) @@ -59,6 +70,15 @@ export const getServerSideProps: GetServerSideProps = async (ct link_url: '/api/auth/oauth/google?state=link', }); + if (oidcEnabled) + oauth_providers.push({ + name: config.oauth.oidc_provider_display_name, + url: '/api/auth/oauth/oidc', + link_url: '/api/auth/oauth/oidc?state=link', + }); + + console.log(oauth_providers); + const obj = { props: { title: config.website.title, diff --git a/src/lib/middleware/withOAuth.ts b/src/lib/middleware/withOAuth.ts index 11a131b55..8d43a5ab0 100644 --- a/src/lib/middleware/withOAuth.ts +++ b/src/lib/middleware/withOAuth.ts @@ -25,7 +25,7 @@ export interface OAuthResponse { export const withOAuth = ( - provider: 'discord' | 'github' | 'google', + provider: 'discord' | 'github' | 'google' | 'oidc', oauth: (query: OAuthQuery, logger: Logger) => Promise ) => async (req: NextApiReq, res: NextApiRes) => { diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 4e30d6818..365c1610b 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -48,3 +48,21 @@ export const google_auth = { return res.json(); }, }; + +export const oidc_auth = { + oauth_url: (authorizeUrl: string, clientId: string, origin: string, scope: string, state?: string) => + `${authorizeUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent( + `${origin}/api/auth/oauth/oidc` + )}&response_type=code&access_type=offline&scope=${scope}${state ? `&state=${state}` : ''}`, + oauth_user: async (userinfoUrl: string, access_token: string) => { + const res = await fetch(`${userinfoUrl}`, { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + console.log('userinfo', res); + if (!res.ok) return null; + + return res.json(); + }, +}; diff --git a/src/pages/api/auth/oauth/oidc.ts b/src/pages/api/auth/oauth/oidc.ts new file mode 100644 index 000000000..1f65d4e01 --- /dev/null +++ b/src/pages/api/auth/oauth/oidc.ts @@ -0,0 +1,82 @@ +import config from 'lib/config'; +import Logger from 'lib/logger'; +import { OAuthQuery, OAuthResponse, withOAuth } from 'lib/middleware/withOAuth'; +import { withZipline } from 'lib/middleware/withZipline'; +import { oidc_auth } from 'lib/oauth'; +import { isNotNullOrUndefined } from 'lib/util'; + +async function handler({ code, state, host }: OAuthQuery, logger: Logger): Promise { + if (!config.features.oauth_registration) + return { + error_code: 403, + error: 'oauth registration is disabled', + }; + + if ( + !isNotNullOrUndefined(config.oauth.oidc_client_id) && + !isNotNullOrUndefined(config.oauth.oidc_client_secret) && + !isNotNullOrUndefined(config.oauth.oidc_authorize_url) && + !isNotNullOrUndefined(config.oauth.oidc_token_url) && + !isNotNullOrUndefined(config.oauth.oidc_userinfo_url) && + !isNotNullOrUndefined(config.oauth.oidc_scopes) && + !isNotNullOrUndefined(config.oauth.oidc_name_field) && + !isNotNullOrUndefined(config.oauth.oidc_user_id_field) && + !isNotNullOrUndefined(config.oauth.oidc_provider_display_name) + ) { + logger.error('OIDC OAuth is not configured'); + return { + error_code: 401, + error: 'OIDC OAuth is not configured', + }; + } + + if (!code) { + return { + redirect: oidc_auth.oauth_url( + config.oauth.oidc_authorize_url, + config.oauth.oidc_client_id, + `${config.core.return_https ? 'https' : 'http'}://${host}`, + config.oauth.oidc_scopes, + state + ), + }; + } + + const body = new URLSearchParams({ + code, + client_id: config.oauth.oidc_client_id, + client_secret: config.oauth.oidc_client_secret, + redirect_uri: `${config.core.return_https ? 'https' : 'http'}://${host}/api/auth/oauth/oidc`, + grant_type: 'authorization_code', + }); + + const resp = await fetch(config.oauth.oidc_token_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const text = await resp.text(); + console.log(`oauth ${config.oauth.oidc_token_url} -> body(${body}) resp(${text})`); + + if (!resp.ok) return { error: 'invalid request' }; + + const json = JSON.parse(text); + + if (!json.access_token) return { error: 'no access_token in response' }; + + const userJson = await oidc_auth.oauth_user(config.oauth.oidc_userinfo_url, json.access_token); + + if (!userJson) return { error: 'invalid user request' }; + + return { + username: userJson[config.oauth.oidc_name_field], + user_id: userJson[config.oauth.oidc_user_id_field], + access_token: json.access_token, + refresh_token: json.refresh_token, + }; +} + +export default withZipline(withOAuth('oidc', handler)); diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index e9cc3c123..600b11f70 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -1,6 +1,6 @@ import zconfig from 'lib/config'; import Logger from 'lib/logger'; -import { discord_auth, github_auth, google_auth } from 'lib/oauth'; +import { discord_auth, github_auth, google_auth, oidc_auth } from 'lib/oauth'; import prisma from 'lib/prisma'; import { hashPassword } from 'lib/util'; import { jsonUserReplacer } from 'lib/utils/client'; @@ -121,6 +121,65 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) { const json = await resp2.json(); + await prisma.oAuth.update({ + where: { + id: provider.id, + }, + data: { + token: json.access_token, + refresh: json.refresh_token, + }, + }); + } + } else if (user.oauth.find((o) => o.provider === 'OIDC')) { + const resp = await fetch( + `${zconfig.oauth.oidc_userinfo_url}?access_token=${ + user.oauth.find((o) => o.provider === 'OIDC').token + }` + ); + if (!resp.ok) { + const provider = user.oauth.find((o) => o.provider === 'OIDC'); + if (!provider.refresh) { + logger.debug(`couldn't find a refresh token for ${JSON.stringify(user, jsonUserReplacer)}`); + + return res.json({ + error: 'oauth token expired', + redirect_uri: oidc_auth.oauth_url( + zconfig.oauth.oidc_authorize_url, + zconfig.oauth.oidc_client_id, + `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, + zconfig.oauth.oidc_scopes + ), + }); + } + const resp2 = await fetch(zconfig.oauth.oidc_token_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: zconfig.oauth.oidc_client_id, + client_secret: zconfig.oauth.oidc_client_secret, + grant_type: 'refresh_token', + refresh_token: provider.refresh, + }), + }); + if (!resp2.ok) { + logger.debug(`oauth expired for ${JSON.stringify(user, jsonUserReplacer)}`); + + return res.json({ + error: 'oauth token expired', + redirect_uri: oidc_auth.oauth_url( + zconfig.oauth.oidc_authorize_url, + zconfig.oauth.oidc_client_id, + `${zconfig.core.return_https ? 'https' : 'http'}://${req.headers.host}`, + zconfig.oauth.oidc_scopes + ), + }); + } + + const json = await resp2.json(); + await prisma.oAuth.update({ where: { id: provider.id, diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 1b550d7ab..134cf20e8 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -14,7 +14,7 @@ import { Title, } from '@mantine/core'; import { useForm } from '@mantine/form'; -import { IconBrandDiscordFilled, IconBrandGithub, IconBrandGoogle } from '@tabler/icons-react'; +import { IconBrandDiscordFilled, IconBrandGithub, IconBrandGoogle, IconLogin } from '@tabler/icons-react'; import useFetch from 'hooks/useFetch'; import Head from 'next/head'; import Link from 'next/link'; @@ -47,6 +47,7 @@ export default function Login({ GitHub: IconBrandGithub, Discord: IconBrandDiscordFilled, Google: IconBrandGoogle, + OIDC: IconLogin, }; for (const provider of oauth_providers) {