diff --git a/api/oauth/sso/:issuer/callback/index.ts b/api/oauth/sso/:issuer/callback/index.ts deleted file mode 100644 index dd7d891f..00000000 --- a/api/oauth/sso/:issuer/callback/index.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { apiRoute, applyConfig } from "@/api"; -import { randomString } from "@/math"; -import { setCookie } from "@hono/hono/cookie"; -import { createRoute } from "@hono/zod-openapi"; -import { and, eq, isNull } from "drizzle-orm"; -import type { Context } from "hono"; -import { SignJWT } from "jose"; -import { z } from "zod"; -import { TokenType } from "~/classes/functions/token"; -import { db } from "~/drizzle/db"; -import { RolePermissions, Tokens, Users } from "~/drizzle/schema"; -import { config } from "~/packages/config-manager"; -import { OAuthManager } from "~/packages/database-interface/oauth"; -import { User } from "~/packages/database-interface/user"; - -export const meta = applyConfig({ - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/oauth/sso/:issuer/callback", -}); - -export const schemas = { - query: z.object({ - client_id: z.string().optional(), - flow: z.string(), - link: z - .string() - .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) - .optional(), - user_id: z.string().uuid().optional(), - }), - param: z.object({ - issuer: z.string(), - }), -}; - -const route = createRoute({ - method: "get", - path: "/oauth/sso/{issuer}/callback", - summary: "SSO callback", - description: - "After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code", - request: { - query: schemas.query, - params: schemas.param, - }, - responses: { - 302: { - description: - "Redirect to frontend's consent route, or redirect to login page with error", - }, - }, -}); - -const returnError = ( - context: Context, - query: object, - error: string, - description: string, -) => { - const searchParams = new URLSearchParams(); - - // Add all data that is not undefined except email and password - for (const [key, value] of Object.entries(query)) { - if (key !== "email" && key !== "password" && value !== undefined) { - searchParams.append(key, value); - } - } - - searchParams.append("error", error); - searchParams.append("error_description", description); - - return context.redirect( - `${config.frontend.routes.login}?${searchParams.toString()}`, - ); -}; - -export default apiRoute((app) => - app.openapi(route, async (context) => { - const currentUrl = new URL(context.req.url); - const redirectUrl = new URL(context.req.url); - - // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https - // Looking at you, Traefik - if ( - new URL(config.http.base_url).protocol === "https:" && - currentUrl.protocol === "http:" - ) { - currentUrl.protocol = "https:"; - redirectUrl.protocol = "https:"; - } - - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - redirectUrl.searchParams.delete("state"); - // Remove issuer query parameter from URL (can cause redirect URI mismatches) - redirectUrl.searchParams.delete("iss"); - redirectUrl.searchParams.delete("code"); - const { issuer: issuerParam } = context.req.valid("param"); - const { flow: flowId, user_id, link } = context.req.valid("query"); - - const manager = new OAuthManager(issuerParam); - - const userInfo = await manager.automaticOidcFlow( - flowId, - currentUrl, - redirectUrl, - (error, message, app) => - returnError( - context, - OAuthManager.processOAuth2Error(app), - error, - message, - ), - ); - - if (userInfo instanceof Response) { - return userInfo; - } - - const { sub, email, preferred_username, picture } = userInfo.userInfo; - const flow = userInfo.flow; - - // If linking account - if (link && user_id) { - return await manager.linkUser(user_id, context, userInfo); - } - - let userId = ( - await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.serverId, sub), - eq(account.issuerId, manager.issuer.id), - ), - }) - )?.userId; - - if (!userId) { - // Register new user - if (config.signups.registration && config.oidc.allow_registration) { - let username = - preferred_username ?? - email?.split("@")[0] ?? - randomString(8, "hex"); - - const usernameValidator = z - .string() - .regex(/^[a-z0-9_]+$/) - .min(3) - .max(config.validation.max_username_size) - .refine( - (value) => - !config.validation.username_blacklist.includes( - value, - ), - ) - .refine((value) => - config.filters.username.some((filter) => - value.match(filter), - ), - ) - .refine( - async (value) => - !(await User.fromSql( - and( - eq(Users.username, value), - isNull(Users.instanceId), - ), - )), - ); - - try { - await usernameValidator.parseAsync(username); - } catch { - username = randomString(8, "hex"); - } - - const doesEmailExist = email - ? !!(await User.fromSql(eq(Users.email, email))) - : false; - - // Create new user - const user = await User.fromDataLocal({ - email: doesEmailExist ? undefined : email, - username, - avatar: picture, - password: undefined, - }); - - // Link account - await manager.linkUserInDatabase(user.id, sub); - - userId = user.id; - } else { - return returnError( - context, - { - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }, - "invalid_request", - "No user found with that account", - ); - } - } - - const user = await User.fromId(userId); - - if (!user) { - return returnError( - context, - { - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }, - "invalid_request", - "No user found with that account", - ); - } - - if (!user.hasPermission(RolePermissions.OAuth)) { - return returnError( - context, - { - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }, - "invalid_request", - `User does not have the '${RolePermissions.OAuth}' permission`, - ); - } - - if (!flow.application) { - return context.json({ error: "Application not found" }, 500); - } - - const code = randomString(32, "hex"); - - await db.insert(Tokens).values({ - accessToken: randomString(64, "base64url"), - code, - scope: flow.application.scopes, - tokenType: TokenType.Bearer, - userId: user.id, - applicationId: flow.application.id, - }); - - // Try and import the key - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(config.oidc.keys?.private ?? "", "base64"), - "Ed25519", - false, - ["sign"], - ); - - // Generate JWT - const jwt = await new SignJWT({ - sub: user.id, - iss: new URL(config.http.base_url).origin, - aud: flow.application.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); - - // Redirect back to application - setCookie(context, "jwt", jwt, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 60, - }); - - return context.redirect( - new URL( - `${config.frontend.routes.consent}?${new URLSearchParams({ - redirect_uri: flow.application.redirectUri, - code, - client_id: flow.application.clientId, - application: flow.application.name, - website: flow.application.website ?? "", - scope: flow.application.scopes, - response_type: "code", - }).toString()}`, - config.http.base_url, - ).toString(), - ); - }), -); diff --git a/packages/database-interface/oauth.ts b/packages/database-interface/oauth.ts deleted file mode 100644 index a3aa8671..00000000 --- a/packages/database-interface/oauth.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { InferInsertModel } from "drizzle-orm"; -import type { Context } from "hono"; -import { - type AuthorizationServer, - authorizationCodeGrantRequest, - discoveryRequest, - expectNoState, - getValidatedIdTokenClaims, - isOAuth2Error, - processAuthorizationCodeOpenIDResponse, - processDiscoveryResponse, - processUserInfoResponse, - userInfoRequest, - validateAuthResponse, -} from "oauth4webapi"; -import type { Application } from "~/classes/functions/application"; -import { db } from "~/drizzle/db"; -import { type Applications, OpenIdAccounts } from "~/drizzle/schema"; -import { config } from "~/packages/config-manager"; - -export class OAuthManager { - public issuer: (typeof config.oidc.providers)[0]; - - constructor(public issuerId: string) { - const found = config.oidc.providers.find( - (provider) => provider.id === this.issuerId, - ); - - if (!found) { - throw new Error(`Issuer ${this.issuerId} not found`); - } - - this.issuer = found; - } - - static async getFlow(flowId: string) { - return await db.query.OpenIdLoginFlows.findFirst({ - where: (flow, { eq }) => eq(flow.id, flowId), - with: { - application: true, - }, - }); - } - - static async getAuthServer(issuerUrl: URL) { - return await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - } - - static getParameters( - authServer: AuthorizationServer, - issuer: (typeof config.oidc.providers)[0], - currentUrl: URL, - ) { - return validateAuthResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - currentUrl, - expectNoState, - ); - } - - static async getOIDCResponse( - authServer: AuthorizationServer, - issuer: (typeof config.oidc.providers)[0], - redirectUri: string, - codeVerifier: string, - parameters: URLSearchParams, - ) { - return await authorizationCodeGrantRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - parameters, - redirectUri, - codeVerifier, - ); - } - - static async processOIDCResponse( - authServer: AuthorizationServer, - issuer: (typeof config.oidc.providers)[0], - oidcResponse: Response, - ) { - return await processAuthorizationCodeOpenIDResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - oidcResponse, - ); - } - - static async getUserInfo( - authServer: AuthorizationServer, - issuer: (typeof config.oidc.providers)[0], - accessToken: string, - sub: string, - ) { - return await userInfoRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - accessToken, - ).then( - async (res) => - await processUserInfoResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - sub, - res, - ), - ); - } - - static processOAuth2Error( - application: InferInsertModel | null, - ) { - return { - redirect_uri: application?.redirectUri, - client_id: application?.clientId, - response_type: "code", - scope: application?.scopes, - }; - } - - async linkUserInDatabase(userId: string, sub: string): Promise { - await db.insert(OpenIdAccounts).values({ - serverId: sub, - issuerId: this.issuer.id, - userId, - }); - } - - async linkUser( - userId: string, - context: Context, - // Return value of automaticOidcFlow - oidcFlowData: Exclude< - Awaited< - ReturnType - >, - Response - >, - ) { - const { flow, userInfo } = oidcFlowData; - - // Check if userId is equal to application.clientId - if (!flow.application?.clientId.startsWith(userId)) { - return context.redirect( - `${config.http.base_url}${ - config.frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linking_error: "Account linking error", - oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`, - })}`, - ); - } - - // Check if account is already linked - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and( - eq(account.serverId, userInfo.sub), - eq(account.issuerId, this.issuer.id), - ), - }); - - if (account) { - return context.redirect( - `${config.http.base_url}${ - config.frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linking_error: "Account already linked", - oidc_account_linking_error_message: - "This account has already been linked to this OpenID Connect provider.", - })}`, - ); - } - - // Link the account - await this.linkUserInDatabase(userId, userInfo.sub); - - return context.redirect( - `${config.http.base_url}${ - config.frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linked: "true", - })}`, - ); - } - - async automaticOidcFlow( - flowId: string, - currentUrl: URL, - redirectUrl: URL, - errorFn: ( - error: string, - message: string, - app: Application | null, - ) => Response, - ) { - const flow = await OAuthManager.getFlow(flowId); - - if (!flow) { - return errorFn("invalid_request", "Invalid flow", null); - } - - const issuerUrl = new URL(this.issuer.url); - - const authServer = await OAuthManager.getAuthServer(issuerUrl); - - const parameters = await OAuthManager.getParameters( - authServer, - this.issuer, - currentUrl, - ); - - if (isOAuth2Error(parameters)) { - return errorFn( - parameters.error, - parameters.error_description || "", - flow.application, - ); - } - - const oidcResponse = await OAuthManager.getOIDCResponse( - authServer, - this.issuer, - redirectUrl.toString(), - flow.codeVerifier, - parameters, - ); - - const result = await OAuthManager.processOIDCResponse( - authServer, - this.issuer, - oidcResponse, - ); - - if (isOAuth2Error(result)) { - return errorFn( - result.error, - result.error_description || "", - flow.application, - ); - } - - const { access_token } = result; - - const claims = getValidatedIdTokenClaims(result); - const { sub } = claims; - - // Validate `sub` - // Later, we'll use this to automatically set the user's data - const userInfo = await OAuthManager.getUserInfo( - authServer, - this.issuer, - access_token, - sub, - ); - - return { - userInfo, - flow, - claims, - }; - } -} diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index 1080f328..31710488 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -2,6 +2,7 @@ import { Hooks, Plugin } from "@versia/kit"; import { z } from "zod"; import authorizeRoute from "./routes/authorize.ts"; import jwksRoute from "./routes/jwks.ts"; +import ssoLoginCallbackRoute from "./routes/oauth/callback.ts"; import tokenRevokeRoute from "./routes/oauth/revoke.ts"; import ssoLoginRoute from "./routes/oauth/sso.ts"; import tokenRoute from "./routes/oauth/token.ts"; @@ -78,6 +79,7 @@ tokenRoute(plugin); tokenRevokeRoute(plugin); jwksRoute(plugin); ssoLoginRoute(plugin); +ssoLoginCallbackRoute(plugin); export type PluginType = typeof plugin; export default plugin; diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts new file mode 100644 index 00000000..461b5378 --- /dev/null +++ b/plugins/openid/routes/oauth/callback.ts @@ -0,0 +1,353 @@ +import { randomString } from "@/math.ts"; +import { setCookie } from "@hono/hono/cookie"; +import { createRoute, z } from "@hono/zod-openapi"; +import { User, db } from "@versia/kit/db"; +import { and, eq, isNull } from "@versia/kit/drizzle"; +import { + OpenIdAccounts, + RolePermissions, + Tokens, + Users, +} from "@versia/kit/tables"; +import { SignJWT } from "jose"; +import { TokenType } from "~/classes/functions/token.ts"; +import type { PluginType } from "../../index.ts"; +import { automaticOidcFlow } from "../../utils.ts"; + +export const schemas = { + query: z.object({ + client_id: z.string().optional(), + flow: z.string(), + link: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + user_id: z.string().uuid().optional(), + }), + param: z.object({ + issuer: z.string(), + }), +}; + +export default (plugin: PluginType) => { + plugin.registerRoute("/oauth/sso/{issuer}/callback", (app) => { + app.openapi( + createRoute({ + method: "get", + path: "/oauth/sso/{issuer}/callback", + summary: "SSO callback", + description: + "After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code", + request: { + query: schemas.query, + params: schemas.param, + }, + responses: { + 302: { + description: + "Redirect to frontend's consent route, or redirect to login page with error", + }, + }, + }), + async (context) => { + const currentUrl = new URL(context.req.url); + const redirectUrl = new URL(context.req.url); + + // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https + // Looking at you, Traefik + if ( + new URL(context.get("config").http.base_url).protocol === + "https:" && + currentUrl.protocol === "http:" + ) { + currentUrl.protocol = "https:"; + redirectUrl.protocol = "https:"; + } + + // Remove state query parameter from URL + currentUrl.searchParams.delete("state"); + redirectUrl.searchParams.delete("state"); + // Remove issuer query parameter from URL (can cause redirect URI mismatches) + redirectUrl.searchParams.delete("iss"); + redirectUrl.searchParams.delete("code"); + const { issuer: issuerParam } = context.req.valid("param"); + const { + flow: flowId, + user_id, + link, + } = context.req.valid("query"); + + const issuer = context + .get("pluginConfig") + .providers.find((provider) => provider.id === issuerParam); + + if (!issuer) { + return context.json({ error: "Issuer not found" }, 404); + } + + const userInfo = await automaticOidcFlow( + issuer, + flowId, + currentUrl, + redirectUrl, + (error, message) => { + errorSearchParams.append("error", error); + errorSearchParams.append("error_description", message); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + }, + ); + + if (userInfo instanceof Response) { + return userInfo; + } + + const { sub, email, preferred_username, picture } = + userInfo.userInfo; + const flow = userInfo.flow; + + const errorSearchParams = new URLSearchParams( + Object.entries({ + redirect_uri: flow.application?.redirectUri, + client_id: flow.application?.clientId, + response_type: "code", + scope: flow.application?.scopes, + }).filter(([_, value]) => value !== undefined) as [ + string, + string, + ][], + ); + + // If linking account + if (link && user_id) { + // Check if userId is equal to application.clientId + if (!flow.application?.clientId.startsWith(user_id)) { + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linking_error: + "Account linking error", + oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`, + })}`, + ); + } + + // Check if account is already linked + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.serverId, sub), + eq(account.issuerId, issuer.id), + ), + }); + + if (account) { + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linking_error: + "Account already linked", + oidc_account_linking_error_message: + "This account has already been linked to this OpenID Connect provider.", + })}`, + ); + } + + // Link the account + await db.insert(OpenIdAccounts).values({ + serverId: sub, + issuerId: issuer.id, + userId: user_id, + }); + + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linked: "true", + })}`, + ); + } + + let userId = ( + await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.serverId, sub), + eq(account.issuerId, issuer.id), + ), + }) + )?.userId; + + if (!userId) { + // Register new user + if ( + context.get("config").signups.registration && + context.get("pluginConfig").allow_registration + ) { + let username = + preferred_username ?? + email?.split("@")[0] ?? + randomString(8, "hex"); + + const usernameValidator = z + .string() + .regex(/^[a-z0-9_]+$/) + .min(3) + .max( + context.get("config").validation + .max_username_size, + ) + .refine( + (value) => + !context + .get("config") + .validation.username_blacklist.includes( + value, + ), + ) + .refine((value) => + context + .get("config") + .filters.username.some((filter) => + value.match(filter), + ), + ) + .refine( + async (value) => + !(await User.fromSql( + and( + eq(Users.username, value), + isNull(Users.instanceId), + ), + )), + ); + + try { + await usernameValidator.parseAsync(username); + } catch { + username = randomString(8, "hex"); + } + + const doesEmailExist = email + ? !!(await User.fromSql(eq(Users.email, email))) + : false; + + // Create new user + const user = await User.fromDataLocal({ + email: doesEmailExist ? undefined : email, + username, + avatar: picture, + password: undefined, + }); + + // Link account + await db.insert(OpenIdAccounts).values({ + serverId: sub, + issuerId: issuer.id, + userId: user.id, + }); + + userId = user.id; + } else { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "No user found with that account", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + } + + const user = await User.fromId(userId); + + if (!user) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "No user found with that account", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + if (!user.hasPermission(RolePermissions.OAuth)) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + `User does not have the '${RolePermissions.OAuth}' permission`, + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + if (!flow.application) { + return context.json( + { error: "Application not found" }, + 500, + ); + } + + const code = randomString(32, "hex"); + + await db.insert(Tokens).values({ + accessToken: randomString(64, "base64url"), + code, + scope: flow.application.scopes, + tokenType: TokenType.Bearer, + userId: user.id, + applicationId: flow.application.id, + }); + + // Generate JWT + const jwt = await new SignJWT({ + sub: user.id, + iss: new URL(context.get("config").http.base_url).origin, + aud: flow.application.clientId, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + iat: Math.floor(Date.now() / 1000), + nbf: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ alg: "EdDSA" }) + .sign(context.get("pluginConfig").keys?.private); + + // Redirect back to application + setCookie(context, "jwt", jwt, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60, + }); + + return context.redirect( + new URL( + `${context.get("config").frontend.routes.consent}?${new URLSearchParams( + { + redirect_uri: flow.application.redirectUri, + code, + client_id: flow.application.clientId, + application: flow.application.name, + website: flow.application.website ?? "", + scope: flow.application.scopes, + response_type: "code", + }, + ).toString()}`, + context.get("config").http.base_url, + ).toString(), + ); + }, + ); + }); +}; diff --git a/plugins/openid/routes/oauth/sso.ts b/plugins/openid/routes/oauth/sso.ts index 08c92292..1368f742 100644 --- a/plugins/openid/routes/oauth/sso.ts +++ b/plugins/openid/routes/oauth/sso.ts @@ -1,12 +1,12 @@ import { createRoute, z } from "@hono/zod-openapi"; +import { db } from "@versia/kit/db"; +import { OpenIdLoginFlows } from "@versia/kit/tables"; import { calculatePKCECodeChallenge, discoveryRequest, generateRandomCodeVerifier, processDiscoveryResponse, } from "oauth4webapi"; -import { db } from "~/drizzle/db.ts"; -import { OpenIdLoginFlows } from "~/drizzle/schema.ts"; import type { PluginType } from "../../index.ts"; import { oauthRedirectUri } from "../../utils.ts"; diff --git a/plugins/openid/utils.ts b/plugins/openid/utils.ts index 2c6d2583..79e6b565 100644 --- a/plugins/openid/utils.ts +++ b/plugins/openid/utils.ts @@ -1,8 +1,20 @@ +import { db } from "@versia/kit/db"; import { type AuthorizationServer, + type OAuth2Error, + type OpenIDTokenEndpointResponse, + authorizationCodeGrantRequest, discoveryRequest, + expectNoState, + getValidatedIdTokenClaims, + isOAuth2Error, + processAuthorizationCodeOpenIDResponse, processDiscoveryResponse, + processUserInfoResponse, + userInfoRequest, + validateAuthResponse, } from "oauth4webapi"; +import type { Application } from "~/classes/functions/application"; export const oauthDiscoveryRequest = ( issuerUrl: string | URL, @@ -16,3 +28,185 @@ export const oauthDiscoveryRequest = ( export const oauthRedirectUri = (baseUrl: string, issuer: string) => new URL(`/oauth/sso/${issuer}/callback`, baseUrl).toString(); + +const getFlow = (flowId: string) => { + return db.query.OpenIdLoginFlows.findFirst({ + where: (flow, { eq }) => eq(flow.id, flowId), + with: { + application: true, + }, + }); +}; + +const getAuthServer = (issuerUrl: URL): Promise => { + return discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); +}; + +const getParameters = ( + authServer: AuthorizationServer, + clientId: string, + clientSecret: string, + currentUrl: URL, +): URLSearchParams | OAuth2Error => { + return validateAuthResponse( + authServer, + { + client_id: clientId, + client_secret: clientSecret, + }, + currentUrl, + expectNoState, + ); +}; + +const getOIDCResponse = ( + authServer: AuthorizationServer, + clientId: string, + clientSecret: string, + redirectUri: string, + codeVerifier: string, + parameters: URLSearchParams, +): Promise => { + return authorizationCodeGrantRequest( + authServer, + { + client_id: clientId, + client_secret: clientSecret, + }, + parameters, + redirectUri, + codeVerifier, + ); +}; + +const processOIDCResponse = ( + authServer: AuthorizationServer, + clientId: string, + clientSecret: string, + oidcResponse: Response, +): Promise => { + return processAuthorizationCodeOpenIDResponse( + authServer, + { + client_id: clientId, + client_secret: clientSecret, + }, + oidcResponse, + ); +}; + +const getUserInfo = ( + authServer: AuthorizationServer, + clientId: string, + clientSecret: string, + accessToken: string, + sub: string, +) => { + return userInfoRequest( + authServer, + { + client_id: clientId, + client_secret: clientSecret, + }, + accessToken, + ).then( + async (res) => + await processUserInfoResponse( + authServer, + { + client_id: clientId, + client_secret: clientId, + }, + sub, + res, + ), + ); +}; + +export const automaticOidcFlow = async ( + issuer: { + url: string; + client_id: string; + client_secret: string; + }, + flowId: string, + currentUrl: URL, + redirectUrl: URL, + errorFn: ( + error: string, + message: string, + app: Application | null, + ) => Response, +) => { + const flow = await getFlow(flowId); + + if (!flow) { + return errorFn("invalid_request", "Invalid flow", null); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await getAuthServer(issuerUrl); + + const parameters = await getParameters( + authServer, + issuer.client_id, + issuer.client_secret, + currentUrl, + ); + + if (isOAuth2Error(parameters)) { + return errorFn( + parameters.error, + parameters.error_description || "", + flow.application, + ); + } + + const oidcResponse = await getOIDCResponse( + authServer, + issuer.client_id, + issuer.client_secret, + redirectUrl.toString(), + flow.codeVerifier, + parameters, + ); + + const result = await processOIDCResponse( + authServer, + issuer.client_id, + issuer.client_secret, + oidcResponse, + ); + + if (isOAuth2Error(result)) { + return errorFn( + result.error, + result.error_description || "", + flow.application, + ); + } + + const { access_token } = result; + + const claims = getValidatedIdTokenClaims(result); + const { sub } = claims; + + // Validate `sub` + // Later, we'll use this to automatically set the user's data + const userInfo = await getUserInfo( + authServer, + issuer.client_id, + issuer.client_secret, + access_token, + sub, + ); + + return { + userInfo, + flow, + claims, + }; +}; diff --git a/utils/init.ts b/utils/init.ts index 5ced90be..4f3d17bf 100644 --- a/utils/init.ts +++ b/utils/init.ts @@ -4,8 +4,6 @@ import type { Config } from "~/packages/config-manager"; import { User } from "~/packages/database-interface/user"; export const checkConfig = async (config: Config) => { - await checkOidcConfig(config); - await checkFederationConfig(config); await checkHttpProxyConfig(config); @@ -67,64 +65,6 @@ const checkChallengeConfig = async (config: Config) => { } }; -const checkOidcConfig = async (config: Config) => { - const logger = getLogger("server"); - - if (!(config.oidc.keys?.private && config.oidc.keys?.public)) { - logger.fatal`The OpenID keys are not set in the config`; - logger.fatal`Below are generated key for you to copy in the config at oidc.keys`; - - // Generate a key for them - const keys = await crypto.subtle.generateKey("Ed25519", true, [ - "sign", - "verify", - ]); - - const privateKey = Buffer.from( - await crypto.subtle.exportKey("pkcs8", keys.privateKey), - ).toString("base64"); - - const publicKey = Buffer.from( - await crypto.subtle.exportKey("spki", keys.publicKey), - ).toString("base64"); - - logger.fatal`Generated keys:`; - logger.fatal`Private key: ${chalk.gray(privateKey)}`; - logger.fatal`Public key: ${chalk.gray(publicKey)}`; - - // Hang until Ctrl+C is pressed - await Bun.sleep(Number.POSITIVE_INFINITY); - } - - // Try and import the key - const privateKey = await crypto.subtle - .importKey( - "pkcs8", - Buffer.from(config.oidc.keys?.private ?? "", "base64"), - "Ed25519", - false, - ["sign"], - ) - .catch((e) => e as Error); - - // Try and import the key - const publicKey = await crypto.subtle - .importKey( - "spki", - Buffer.from(config.oidc.keys?.public ?? "", "base64"), - "Ed25519", - false, - ["verify"], - ) - .catch((e) => e as Error); - - if (privateKey instanceof Error || publicKey instanceof Error) { - throw new Error( - "The OpenID keys could not be imported! You may generate a new one by removing the old ones from config and restarting the server (this will invalidate all current JWTs).", - ); - } -}; - const checkFederationConfig = async (config: Config) => { const logger = getLogger("server");