feat: overhaul oauth + PKCE for OIDC

refactored a lot of oauth stuff, so there may be bugs
This commit is contained in:
diced
2026-04-13 21:33:11 -07:00
parent 82e1fe4824
commit 7e3bba5e55
17 changed files with 349 additions and 248 deletions

View File

@@ -61,11 +61,14 @@ export const API_ERRORS = {
1060: 'Passkey has legacy registration data and cannot be used',
1061: 'Invalid multipart/form-data request',
1062: 'No files in multipart/form-data request',
1063: 'Already linked to this OAuth provider',
1064: 'Invalid OAuth state parameter',
// 2xxx, session errors
2000: 'Invalid login session',
2001: 'Invalid token',
2002: 'Not logged in',
2003: 'OAuth provider is not configured (or misconfigured)',
// 3xxx, permission errors
3000: 'Admin only',
@@ -84,6 +87,8 @@ export const API_ERRORS = {
3013: "You don't have permission to delete the selected files",
3014: "You don't have permission to modify the selected files",
3015: 'Not super admin',
3016: 'OAuth registration is disabled',
3017: 'OAuth login is not allowed for this account',
// 4xxx, not founds
4000: 'File not found',
@@ -109,6 +114,13 @@ export const API_ERRORS = {
6001: 'Failed to fetch version details',
6002: 'Failed to rename file in datasource',
6003: 'There was an error during a healthcheck',
6004: 'Failed to fetch OAuth access token',
6005: 'No access token in OAuth response',
6006: 'No refresh token in OAuth response',
6007: 'Failed to fetch OAuth user',
6008: 'OAuth provider request failed',
6009: "Couldn't create user via OAuth profile",
6010: 'The username is already taken by another account',
// 9xxx catch all
9000: 'Bad request',
@@ -128,14 +140,16 @@ export type ApiErrorPayload = {
};
export class ApiError extends Error {
public readonly code: ApiErrorCode;
public readonly status: number;
public additional: Record<string, any>;
constructor(code: ApiErrorCode, message?: string, status?: number) {
constructor(
public readonly code: ApiErrorCode,
message?: string,
status?: number,
) {
super(message ?? API_ERRORS[code] ?? 'Unknown API error');
this.code = code;
this.status = status ?? ApiError.codeToHttpStatus(code);
this.additional = {} as Record<string, any>;
@@ -184,3 +198,11 @@ export class ApiError extends Error {
return 500;
}
}
export class RedirectError extends Error {
constructor(public readonly url: string) {
super('Redirect');
Object.setPrototypeOf(this, new.target.prototype);
}
}

9
src/lib/oauth/pkce.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createHash, randomBytes } from 'crypto';
export function generatePKCEVerifier(size = 32): string {
return randomBytes(size).toString('base64url');
}
export function generatePKCEChallenge(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url');
}

View File

@@ -1,79 +0,0 @@
import type { OAuthProviderType } from '@/prisma/client';
import { User } from '../db/models/user';
export function findProvider(
provider: OAuthProviderType,
providers: User['oauthProviders'],
): User['oauthProviders'][0] | undefined {
return providers.find((p) => p.provider === provider);
}
export const githubAuth = {
url: (clientId: string, state?: string, redirectUri?: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
state ? `&state=${encodeURIComponent(state)}` : ''
}${redirectUri ? `&redirect_uri=${encodeURIComponent(redirectUri)}` : ''}`,
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, redirectUri?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/discord`,
)}&response_type=code&scope=identify&prompt=none${state ? `&state=${encodeURIComponent(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, redirectUri?: string) =>
`https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/google`,
)}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
state ? `&state=${encodeURIComponent(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 oidcAuth = {
url: (clientId: string, origin: string, authorizeUrl: string, state?: string, redirectUri?: string) =>
`${authorizeUrl}?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri ?? `${origin}/api/auth/oauth/oidc`,
)}&response_type=code&scope=openid+email+profile+offline_access${state ? `&state=${encodeURIComponent(state)}` : ''}`,
user: async (accessToken: string, userInfoUrl: string) => {
const res = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) return null;
return res.json();
},
};

View File

@@ -0,0 +1,22 @@
import { fetchUserInfo, type OAuthOptions, type OAuthUserInfoOptions } from '.';
export function discordAuthorizeURL({ clientId, origin, state, redirectUri }: OAuthOptions): string {
const u = new URL('https://discord.com/api/oauth2/authorize');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/discord`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'identify');
u.searchParams.set('prompt', 'none');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function discordUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://discord.com/api/users/@me',
...options,
});
}

View File

@@ -0,0 +1,20 @@
import { fetchUserInfo, type OAuthOptions, type OAuthUserInfoOptions } from '.';
export function githubAuthorizeURL({ clientId, state, redirectUri, origin }: OAuthOptions): string {
const u = new URL('https://github.com/login/oauth/authorize');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/github`);
u.searchParams.set('scope', 'read:user');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function githubUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://api.github.com/user',
...options,
});
}

View File

@@ -0,0 +1,22 @@
import { fetchUserInfo, type OAuthUserInfoOptions, type OAuthOptions } from '.';
export function googleAuthorizeURL({ clientId, origin, state, redirectUri }: OAuthOptions): string {
const u = new URL('https://accounts.google.com/o/oauth2/auth');
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/google`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('access_type', 'offline');
u.searchParams.set('scope', 'https://www.googleapis.com/auth/userinfo.profile');
if (state) u.searchParams.set('state', state);
return u.toString();
}
export function googleUser(options: OAuthUserInfoOptions) {
return fetchUserInfo({
userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
...options,
});
}

View File

@@ -0,0 +1,40 @@
import type { OAuthProviderType } from '@/prisma/client';
import { User } from '../../db/models/user';
export function findProvider(
provider: OAuthProviderType,
providers: User['oauthProviders'],
): User['oauthProviders'][0] | undefined {
return providers.find((p) => p.provider === provider);
}
export async function fetchUserInfo({ userInfoUrl, accessToken }: OAuthUserInfoOptions): Promise<any | null> {
const res = await fetch(userInfoUrl!, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
return res.json();
}
export type OAuthOptions = {
clientId: string;
origin: string;
state?: string;
redirectUri: string;
authorizeUrl?: string;
codeChallenge?: string;
};
export type OAuthUserInfoOptions = {
accessToken: string;
userInfoUrl?: string;
};
export { discordAuthorizeURL, discordUser } from './discord';
export { githubAuthorizeURL, githubUser } from './github';
export { googleAuthorizeURL, googleUser } from './google';
export { oidcAuthorizeURL, oidcUser } from './oidc';

View File

@@ -0,0 +1,32 @@
import { ApiError } from '@/lib/api/errors';
import { type OAuthOptions, type OAuthUserInfoOptions, fetchUserInfo } from '.';
export function oidcAuthorizeURL({
authorizeUrl,
clientId,
origin,
state,
redirectUri,
codeChallenge,
}: OAuthOptions): string {
if (!authorizeUrl) throw new ApiError(2003);
const u = new URL(authorizeUrl);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri ?? `${origin}/api/auth/oauth/oidc`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('scope', 'openid email profile offline_access');
if (state) u.searchParams.set('state', state);
if (codeChallenge) {
u.searchParams.set('code_challenge_method', 'S256');
u.searchParams.set('code_challenge', codeChallenge);
}
return u.toString();
}
export function oidcUser(options: OAuthUserInfoOptions) {
return fetchUserInfo(options);
}

40
src/lib/oauth/state.ts Normal file
View File

@@ -0,0 +1,40 @@
import { config } from '@/lib/config';
import { decrypt, encrypt } from '@/lib/crypto';
export type OAuthStateJSON = {
mode: 'default' | 'link';
};
export function encryptOAuthState(value: OAuthStateJSON): string {
return encrypt(JSON.stringify(value), config.core.secret);
}
export function decryptOAuthState(state?: string): string | null {
if (!state) return null;
try {
return decrypt(decodeURIComponent(state), config.core.secret);
} catch {
return null;
}
}
export function parseOAuthState(state?: string): OAuthStateJSON | null {
const decrypted = decryptOAuthState(state);
if (!decrypted) return null;
// legacy
if (decrypted === 'link') return { mode: 'link' };
if (decrypted === 'default') return { mode: 'default' };
try {
const parsed = JSON.parse(decrypted) as Partial<OAuthStateJSON>;
if (parsed?.mode !== 'default' && parsed?.mode !== 'link') return null;
return {
mode: parsed.mode,
};
} catch {
return null;
}
}

View File

@@ -63,8 +63,8 @@ async function main() {
}).withTypeProvider<ZodTypeProvider>();
await registerPlugins(server);
await registerRoutes(server, MODE);
registerHandlers(server, MODE);
await registerRoutes(server, MODE);
if (process.env.ZIPLINE_OUTPUT_OPENAPI === 'true') generateOpenApiSpec(server);

View File

@@ -1,29 +1,28 @@
import { config } from '@/lib/config';
import { createToken, decrypt } from '@/lib/crypto';
import { createToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import Logger, { log } from '@/lib/logger';
import { findProvider } from '@/lib/oauth/providers';
import { OAuthProviderType, User } from '@/prisma/client';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fastifyPlugin from 'fastify-plugin';
import { getSession, saveSession } from '../session';
import { getSession, saveSession, ZiplineIronSession } from '../session';
import { parseOAuthState } from '@/lib/oauth/state';
import { ApiError } from '@/lib/api/errors';
export type OAuthQuery = {
state?: string;
code: string;
host: string;
session: ZiplineIronSession;
};
export type OAuthResponse = {
username?: string;
user_id?: string;
access_token?: string;
refresh_token?: string;
username: string;
user_id: string;
access_token: string;
refresh_token?: string | null;
avatar?: string | null;
error?: string;
error_code?: number;
redirect?: string;
};
async function oauthPlugin(fastify: FastifyInstance) {
@@ -36,23 +35,17 @@ async function oauthPlugin(fastify: FastifyInstance) {
handler: (query: OAuthQuery, logger: Logger) => Promise<OAuthResponse>,
) {
const logger = log('api').c('auth').c('oauth').c(provider.toLowerCase());
(this.query as any).host = this.headers.host ?? 'localhost:3000';
const response = await handler(this.query as OAuthQuery, logger);
const session = await getSession(this, reply);
if (response.error) {
logger.warn('invalid oauth request', {
error: response.error,
});
const q = this.query as { state?: string; code?: string };
const query: OAuthQuery = {
state: q.state,
code: q.code ?? '',
host: this.headers.host ?? 'localhost:3000',
session,
};
return reply.internalServerError(response.error);
}
if (response.redirect) {
return reply.redirect(response.redirect);
}
const response = await handler(query, logger);
logger.debug('oauth response', {
response,
@@ -77,7 +70,8 @@ async function oauthPlugin(fastify: FastifyInstance) {
},
});
const { state } = this.query as OAuthQuery;
const state = parseOAuthState(query.state);
if (!state) throw new ApiError(1064);
const user = await prisma.user.findFirst({
where: {
@@ -94,18 +88,10 @@ async function oauthPlugin(fastify: FastifyInstance) {
const userOauth = findProvider(provider, user?.oauthProviders ?? []);
let urlState;
try {
urlState = decrypt(decodeURIComponent(state ?? ''), config.core.secret);
} catch {
urlState = null;
}
if (state.mode === 'link') {
if (!user) throw new ApiError(2000);
if (urlState === 'link') {
if (!user) return reply.unauthorized('invalid session');
if (findProvider(provider, user.oauthProviders))
return reply.badRequest('This account is already linked to this provider');
if (findProvider(provider, user.oauthProviders)) throw new ApiError(1063);
logger.debug('attempting to link oauth account', {
provider,
@@ -145,7 +131,7 @@ async function oauthPlugin(fastify: FastifyInstance) {
error: e,
});
return reply.badRequest('Cant link account, already linked with this provider');
throw new ApiError(1063);
}
} else if (user && userOauth) {
await prisma.oAuthProvider.update({
@@ -199,9 +185,10 @@ async function oauthPlugin(fastify: FastifyInstance) {
oauth: response.username || 'unknown',
ua: this.headers['user-agent'],
});
return reply.badRequest("Can't create users through oauth.");
throw new ApiError(6009);
} else if (existingUser) {
return reply.badRequest('This username is already taken');
throw new ApiError(6010);
}
try {
@@ -242,7 +229,7 @@ async function oauthPlugin(fastify: FastifyInstance) {
response,
});
return reply.badRequest('Cant create user, already linked with this provider');
throw new ApiError(1063);
} else throw e;
}
}

View File

@@ -1,38 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { discordAuth } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { discordAuthorizeURL, discordUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { discord: discordEnabled } = enabled(config);
if (!discordEnabled)
return {
error: 'Discord OAuth is not configured.',
error_code: 401,
};
if (!discordEnabled) throw new ApiError(2003, 'Discord OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: discordAuth.url(
config.oauth.discord.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state === 'link' ? linkState : undefined,
config.oauth.discord.redirectUri ?? undefined,
),
};
throw new RedirectError(
discordAuthorizeURL({
clientId: config.oauth.discord.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.discord.redirectUri!,
}),
);
}
const body = new URLSearchParams({
@@ -65,29 +56,26 @@ async function discordOauth({ code, host, state }: OAuthQuery, logger: Logger):
text,
});
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
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' };
if (!json.access_token) throw new ApiError(6005);
if (!json.refresh_token) throw new ApiError(6006);
const userJson = await discordAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await discordUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { '@me': userJson });
const allowedIds = config.oauth.discord.allowedIds;
const deniedIds = config.oauth.discord.deniedIds;
if (deniedIds && deniedIds.length > 0 && deniedIds.includes(userJson.id)) {
return { error: 'You are not allowed to log in with Discord.' };
}
if (allowedIds && allowedIds.length > 0 && !allowedIds.includes(userJson.id)) {
return { error: 'You are not allowed to log in with Discord.' };
}
if (deniedIds && deniedIds.length > 0 && deniedIds.includes(userJson.id)) throw new ApiError(3017);
if (allowedIds && allowedIds.length > 0 && !allowedIds.includes(userJson.id)) throw new ApiError(3017);
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`

View File

@@ -1,37 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { githubAuth } from '@/lib/oauth/providers';
import { githubAuthorizeURL, githubUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
async function githubOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { github: githubEnabled } = enabled(config);
if (!githubEnabled)
return {
error: 'GitHub OAuth is not configured.',
error_code: 401,
};
if (!githubEnabled) throw new ApiError(2003, 'GitHub OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: githubAuth.url(
config.oauth.github.clientId!,
state === 'link' ? linkState : undefined,
config.oauth.github.redirectUri ?? undefined,
),
};
throw new RedirectError(
githubAuthorizeURL({
clientId: config.oauth.github.clientId!,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.github.redirectUri!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
}),
);
}
const body = JSON.stringify({
@@ -55,10 +47,7 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise
const isJson = res.headers.get('content-type')?.startsWith('application/json');
if (!isJson && !res.ok)
return {
error: 'Failed to fetch access token',
};
if (!isJson && !res.ok) throw new ApiError(6004);
const json = await res.json();
@@ -68,13 +57,15 @@ async function githubOauth({ code, state }: OAuthQuery, logger: Logger): Promise
});
logger.debug('failed to fetch access token', { json, status: res.status });
return { error: 'there was an error while processing github request' };
throw new ApiError(6008);
}
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await githubAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await githubUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { user: userJson });

View File

@@ -1,38 +1,29 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { googleAuth } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { googleAuthorizeURL, googleUser } from '@/lib/oauth/providers';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { google: googleEnabled } = enabled(config);
if (!googleEnabled)
return {
error: 'Google OAuth is not configured.',
error_code: 401,
};
if (!googleEnabled) throw new ApiError(2003, 'Google OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
return {
redirect: googleAuth.url(
config.oauth.google.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state === 'link' ? linkState : undefined,
config.oauth.google.redirectUri ?? undefined,
),
};
throw new RedirectError(
googleAuthorizeURL({
clientId: config.oauth.google.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.google.redirectUri!,
}),
);
}
const body = new URLSearchParams({
@@ -63,16 +54,16 @@ async function googleOauth({ code, host, state }: OAuthQuery, logger: Logger): P
text,
});
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await googleAuth.user(json.access_token);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await googleUser({
accessToken: json.access_token,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { userinfo: userJson });

View File

@@ -1,40 +1,44 @@
import { ApiError, RedirectError } from '@/lib/api/errors';
import { fetchToDataURL } from '@/lib/base64';
import { config } from '@/lib/config';
import { encrypt } from '@/lib/crypto';
import Logger from '@/lib/logger';
import enabled from '@/lib/oauth/enabled';
import { oidcAuth } from '@/lib/oauth/providers';
import { generatePKCEChallenge, generatePKCEVerifier } from '@/lib/oauth/pkce';
import { oidcAuthorizeURL, oidcUser } from '@/lib/oauth/providers';
import { encryptOAuthState } from '@/lib/oauth/state';
import { OAuthQuery, OAuthResponse } from '@/server/plugins/oauth';
import typedPlugin from '@/server/typedPlugin';
async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration)
return {
error: 'OAuth registration is disabled.',
error_code: 403,
};
async function oidcOauth({ code, host, state, session }: OAuthQuery, logger: Logger): Promise<OAuthResponse> {
if (!config.features.oauthRegistration) throw new ApiError(3016);
const { oidc: oidcEnabled } = enabled(config);
if (!oidcEnabled)
return {
error: 'OpenID Connect OAuth is not configured.',
error_code: 401,
};
if (!oidcEnabled) throw new ApiError(2003, 'OpenID Connect OAuth is not configured.');
if (!code) {
const linkState = encrypt('link', config.core.secret);
const defaultState = encrypt('default', config.core.secret);
const pkceVerifier = generatePKCEVerifier();
const codeChallenge = generatePKCEChallenge(pkceVerifier);
return {
redirect: oidcAuth.url(
config.oauth.oidc.clientId!,
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
config.oauth.oidc.authorizeUrl!,
state === 'link' ? linkState : defaultState,
config.oauth.oidc.redirectUri ?? undefined,
),
};
session.pkceVerifier = pkceVerifier;
await session.save();
throw new RedirectError(
oidcAuthorizeURL({
clientId: config.oauth.oidc.clientId!,
origin: `${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}`,
state: encryptOAuthState({ mode: state === 'link' ? 'link' : 'default' }),
redirectUri: config.oauth.oidc.redirectUri!,
authorizeUrl: config.oauth.oidc.authorizeUrl!,
codeChallenge,
}),
);
}
const pkceVerifier = session.pkceVerifier;
if (pkceVerifier) {
delete session.pkceVerifier;
await session.save();
}
const body = new URLSearchParams({
@@ -46,6 +50,9 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro
config.oauth.oidc.redirectUri ??
`${config.core.returnHttpsUrls ? 'https' : 'http'}://${host}/api/auth/oauth/oidc`,
});
if (pkceVerifier) {
body.set('code_verifier', pkceVerifier);
}
logger.debug('oidc oauth request', {
body: body.toString(),
@@ -63,16 +70,17 @@ async function oidcOauth({ code, host, state }: OAuthQuery, logger: Logger): Pro
const text = await res.text();
logger.debug('oidc oauth failed with a non 200 status code', { status: res.status, text });
return {
error: 'Failed to fetch access token',
};
throw new ApiError(6004);
}
const json = await res.json();
if (!json.access_token) return { error: 'No access token in response' };
if (!json.access_token) throw new ApiError(6005);
const userJson = await oidcAuth.user(json.access_token, config.oauth.oidc.userinfoUrl!);
if (!userJson) return { error: 'Failed to fetch user' };
const userJson = await oidcUser({
accessToken: json.access_token,
userInfoUrl: config.oauth.oidc.userinfoUrl!,
});
if (!userJson) throw new ApiError(6007);
logger.debug('user', { userinfo: userJson });

View File

@@ -20,8 +20,12 @@ export type ZiplineSession = {
id: string | null;
sessionId: string | null;
client: ZiplineClient;
pkceVerifier?: string;
};
export type ZiplineIronSession = Awaited<ReturnType<typeof getSession>>;
export async function getSession(
req: FastifyRequest | IncomingMessage,
reply: FastifyReply | ServerResponse<IncomingMessage>,
@@ -48,7 +52,7 @@ export async function getSession(
}
export async function saveSession(
session: Awaited<ReturnType<typeof getSession>>,
session: ZiplineIronSession,
user: { id: string } & Record<string, any>,
overwriteSessions = true,
) {

View File

@@ -1,4 +1,4 @@
import { ApiError } from '@/lib/api/errors';
import { ApiError, RedirectError } from '@/lib/api/errors';
import type { FastifyInstance } from 'fastify';
import { hasZodFastifySchemaValidationErrors, isResponseSerializationError } from 'fastify-type-provider-zod';
@@ -44,6 +44,10 @@ export function registerHandlers(server: FastifyInstance, mode: string) {
});
}
if (error instanceof RedirectError) {
return res.redirect(error.url);
}
if (error instanceof ApiError) {
const apiError = error as ApiError;
return res.status(apiError.status).send(apiError.toJSON());