From 777a39faf5d40d19281c6fce5f1b71ee4dd106da Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Fri, 11 Oct 2024 14:39:25 +0200 Subject: [PATCH] refactor(plugin): :truck: Move SSO login route to OpenID plugin --- api/oauth/sso/index.ts | 152 ----------------------------- plugins/openid/index.ts | 2 + plugins/openid/routes/oauth/sso.ts | 136 ++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 152 deletions(-) delete mode 100644 api/oauth/sso/index.ts create mode 100644 plugins/openid/routes/oauth/sso.ts diff --git a/api/oauth/sso/index.ts b/api/oauth/sso/index.ts deleted file mode 100644 index 2651b0e6..00000000 --- a/api/oauth/sso/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { apiRoute, applyConfig } from "@/api"; -import { oauthRedirectUri } from "@/constants"; -import { createRoute } from "@hono/zod-openapi"; -import type { Context } from "hono"; -import { - calculatePKCECodeChallenge, - discoveryRequest, - generateRandomCodeVerifier, - processDiscoveryResponse, -} from "oauth4webapi"; -import { z } from "zod"; -import { db } from "~/drizzle/db"; -import { OpenIdLoginFlows } from "~/drizzle/schema"; -import { config } from "~/packages/config-manager"; - -export const meta = applyConfig({ - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/oauth/sso", -}); - -export const schemas = { - query: z.object({ - issuer: z.string(), - client_id: z.string().optional(), - redirect_uri: z.string().url().optional(), - scope: z.string().optional(), - response_type: z.enum(["code"]).optional(), - }), -}; - -const route = createRoute({ - method: "get", - path: "/oauth/sso", - summary: "Initiate SSO login flow", - request: { - query: schemas.query, - }, - responses: { - 302: { - description: - "Redirect to SSO login, 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) => { - // This is the Versia client's client_id, not the external OAuth provider's client_id - const { issuer: issuerId, client_id } = context.req.valid("query"); - const body = await context.req.query(); - - if (!client_id || client_id === "undefined") { - return returnError( - context, - body, - "invalid_request", - "client_id is required", - ); - } - - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - return returnError( - context, - body, - "invalid_request", - "issuer is invalid", - ); - } - - const issuerUrl = new URL(issuer.url); - - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - - const codeVerifier = generateRandomCodeVerifier(); - - const application = await db.query.Applications.findFirst({ - where: (application, { eq }) => eq(application.clientId, client_id), - }); - - if (!application) { - return returnError( - context, - body, - "invalid_request", - "client_id is invalid", - ); - } - - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - codeVerifier, - applicationId: application.id, - issuerId, - }) - .returning() - )[0]; - - const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); - - return context.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${ - newFlow.id - }`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString()}`, - ); - }), -); diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index 52f9d99c..1080f328 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import authorizeRoute from "./routes/authorize.ts"; import jwksRoute from "./routes/jwks.ts"; import tokenRevokeRoute from "./routes/oauth/revoke.ts"; +import ssoLoginRoute from "./routes/oauth/sso.ts"; import tokenRoute from "./routes/oauth/token.ts"; import ssoIdRoute from "./routes/sso/:id/index.ts"; import ssoRoute from "./routes/sso/index.ts"; @@ -76,6 +77,7 @@ ssoIdRoute(plugin); tokenRoute(plugin); tokenRevokeRoute(plugin); jwksRoute(plugin); +ssoLoginRoute(plugin); export type PluginType = typeof plugin; export default plugin; diff --git a/plugins/openid/routes/oauth/sso.ts b/plugins/openid/routes/oauth/sso.ts new file mode 100644 index 00000000..08c92292 --- /dev/null +++ b/plugins/openid/routes/oauth/sso.ts @@ -0,0 +1,136 @@ +import { createRoute, z } from "@hono/zod-openapi"; +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"; + +export const schemas = { + query: z.object({ + issuer: z.string(), + client_id: z.string().optional(), + redirect_uri: z.string().url().optional(), + scope: z.string().optional(), + response_type: z.enum(["code"]).optional(), + }), +}; + +export default (plugin: PluginType) => { + plugin.registerRoute("/oauth/sso", (app) => { + app.openapi( + createRoute({ + method: "get", + path: "/oauth/sso", + summary: "Initiate SSO login flow", + request: { + query: schemas.query, + }, + responses: { + 302: { + description: + "Redirect to SSO login, or redirect to login page with error", + }, + }, + }), + async (context) => { + // This is the Versia client's client_id, not the external OAuth provider's client_id + const { issuer: issuerId, client_id } = + context.req.valid("query"); + + const errorSearchParams = new URLSearchParams( + context.req.valid("query"), + ); + + if (!client_id || client_id === "undefined") { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "client_id is required", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + const issuer = context + .get("pluginConfig") + .providers.find((provider) => provider.id === issuerId); + + if (!issuer) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "issuer is invalid", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const codeVerifier = generateRandomCodeVerifier(); + + const application = await db.query.Applications.findFirst({ + where: (application, { eq }) => + eq(application.clientId, client_id), + }); + + if (!application) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "client_id is invalid", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + codeVerifier, + applicationId: application.id, + issuerId, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return context.redirect( + `${authServer.authorization_endpoint}?${new URLSearchParams( + { + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri( + issuerId, + context.get("config").http.base_url, + )}?flow=${newFlow.id}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }, + ).toString()}`, + ); + }, + ); + }); +};