diff --git a/server/api/oauth/callback/:issuer/index.ts b/server/api/oauth/callback/:issuer/index.ts index 26421c01..f2dbd2fd 100644 --- a/server/api/oauth/callback/:issuer/index.ts +++ b/server/api/oauth/callback/:issuer/index.ts @@ -2,8 +2,9 @@ import { randomBytes } from "node:crypto"; import { applyConfig, handleZodError } from "@api"; import { oauthRedirectUri } from "@constants"; import { zValidator } from "@hono/zod-validator"; -import { errorResponse, response } from "@response"; +import { errorResponse, jsonResponse, response } from "@response"; import type { Hono } from "hono"; +import { SignJWT } from "jose"; import { authorizationCodeGrantRequest, discoveryRequest, @@ -19,10 +20,9 @@ import { import { z } from "zod"; import { TokenType } from "~database/entities/Token"; import { db } from "~drizzle/db"; -import { Tokens } from "~drizzle/schema"; +import { OpenIdAccounts, Tokens } from "~drizzle/schema"; import { config } from "~packages/config-manager"; import { User } from "~packages/database-interface/user"; -import { SignJWT } from "jose"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -40,6 +40,11 @@ export const schemas = { query: z.object({ clientId: 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(), @@ -75,7 +80,7 @@ export default (app: Hono) => // Remove state query parameter from URL currentUrl.searchParams.delete("state"); const { issuer: issuerParam } = context.req.valid("param"); - const { flow: flowId, clientId } = context.req.valid("query"); + const { flow: flowId, user_id, link } = context.req.valid("query"); const flow = await db.query.OpenIdLoginFlows.findFirst({ where: (flow, { eq }) => eq(flow.id, flowId), @@ -193,6 +198,37 @@ export default (app: Hono) => ), ); + if (link && user_id) { + // Check if userId is equal to application.clientId + if (!flow.application?.clientId.startsWith(user_id)) { + return errorResponse("User ID does not match application"); + } + + // 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 errorResponse("Account already linked"); + } + + // Link the account + await db.insert(OpenIdAccounts).values({ + serverId: sub, + issuerId: issuer.id, + userId: user_id, + }); + + return response(null, 302, { + Location: config.http.base_url, + }); + } + const userId = ( await db.query.OpenIdAccounts.findFirst({ where: (account, { eq, and }) => diff --git a/server/api/oauth/link/index.ts b/server/api/oauth/link/index.ts new file mode 100644 index 00000000..d2d95a19 --- /dev/null +++ b/server/api/oauth/link/index.ts @@ -0,0 +1,111 @@ +import { randomBytes } from "node:crypto"; +import { applyConfig, auth, handleZodError } from "@api"; +import { oauthRedirectUri } from "@constants"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse, redirect, response } from "@response"; +import type { Hono } from "hono"; +import { + calculatePKCECodeChallenge, + discoveryRequest, + generateRandomCodeVerifier, + processDiscoveryResponse, +} from "oauth4webapi"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Applications, OpenIdLoginFlows } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: true, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/oauth/link", +}); + +export const schemas = { + query: z.object({ + issuer: z.string(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { issuer: issuerId } = context.req.valid("query"); + const { user } = context.req.valid("header"); + + if (!user) { + return errorResponse("Unauthorized", 401); + } + + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return errorResponse(`Issuer ${issuerId} not found`, 404); + } + + 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 + .insert(Applications) + .values({ + clientId: + user.id + randomBytes(32).toString("base64url"), + name: "Lysand", + redirectUri: `${oauthRedirectUri(issuerId)}`, + scopes: "openid profile email", + secret: "", + }) + .returning() + )[0]; + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + codeVerifier, + issuerId, + applicationId: application.id, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return jsonResponse({ + link: `${ + authServer.authorization_endpoint + }?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${ + newFlow.id + }&link=true&user_id=${user.id}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + }); + }, + );