refactor(api): 🛂 Rewrite OpenID auth code to use state for data instead of query parameters

This commit is contained in:
Jesse Wierzbinski 2025-12-10 20:59:08 +01:00
parent 5436be0578
commit a951a08073
No known key found for this signature in database
6 changed files with 51 additions and 46 deletions

View file

@ -227,9 +227,6 @@
"msgpackr-extract",
"protobufjs",
],
"patchedDependencies": {
"openid-client@6.8.1": "patches/openid-client@6.8.1.patch",
},
"catalog": {
"@biomejs/biome": "2.3.4",
"@bull-board/api": "~6.14.2",

View file

@ -215,8 +215,5 @@
"zod": "catalog:",
"zod-openapi": "catalog:",
"zod-validation-error": "catalog:"
},
"patchedDependencies": {
"openid-client@6.8.1": "patches/openid-client@6.8.1.patch"
}
}

View file

@ -5,6 +5,7 @@ import { apiRoute, auth, handleZodError } from "@versia-server/kit/api";
import { Client, db } from "@versia-server/kit/db";
import { OpenIdLoginFlows } from "@versia-server/kit/tables";
import { randomUUIDv7 } from "bun";
import { sign } from "hono/jwt";
import { describeRoute, resolver, validator } from "hono-openapi";
import * as client from "openid-client";
import { z } from "zod";
@ -114,10 +115,6 @@ export default apiRoute((app) => {
code_challenge_method: "S256",
};
if (!oidcConfig.serverMetadata().supportsPKCE()) {
parameters.state = client.randomState();
}
const redirectUri = oauthRedirectUri(
context.get("config").http.base_url,
issuerId,
@ -149,14 +146,24 @@ export default apiRoute((app) => {
.returning()
)[0];
parameters.redirect_uri = `${oauthRedirectUri(
config.http.base_url,
issuerId,
)}?${new URLSearchParams({
const jwt = await sign(
{
flow: newFlow.id,
link: "true",
user_id: user.id,
})}`;
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes expiration
iss: config.http.base_url.toString(),
iat: Math.floor(Date.now() / 1000),
},
config.authentication.key,
);
parameters.state = jwt;
parameters.redirect_uri = `${oauthRedirectUri(
config.http.base_url,
issuerId,
)}`;
const redirectTo = client.buildAuthorizationUrl(
oidcConfig,

View file

@ -1,7 +1,6 @@
import {
Account as AccountSchema,
RolePermission,
zBoolean,
} from "@versia/client/schemas";
import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit";
@ -16,7 +15,7 @@ import {
import { randomUUIDv7 } from "bun";
import { and, eq, isNull, type SQL } from "drizzle-orm";
import { setCookie } from "hono/cookie";
import { sign } from "hono/jwt";
import { sign, verify } from "hono/jwt";
import { describeRoute, validator } from "hono-openapi";
import * as client from "openid-client";
import { z } from "zod";
@ -48,15 +47,13 @@ export default apiRoute((app) => {
validator(
"query",
z.object({
flow: z.string(),
link: zBoolean.default(false),
user_id: z.uuid().optional(),
state: z.string(),
}),
handleZodError,
),
async (context) => {
const { issuer: issuerId } = context.req.valid("param");
const { flow: flowId, user_id, link } = context.req.valid("query");
const { state } = context.req.valid("query");
const issuer = config.authentication.openid_providers.find(
(provider) => provider.id === issuerId,
@ -66,6 +63,16 @@ export default apiRoute((app) => {
throw new ApiError(422, "Unknown or invalid issuer");
}
const jwtPayload = (await verify(state, config.authentication.key, {
iss: config.http.base_url.toString(),
})) as {
flow: string;
link?: boolean;
user_id?: string;
};
const { flow: flowId, link, user_id } = jwtPayload;
const flow = await db.query.OpenIdLoginFlows.findFirst({
where: (flow): SQL | undefined => eq(flow.id, flowId),
with: {
@ -104,7 +111,7 @@ export default apiRoute((app) => {
context.req.raw,
{
pkceCodeVerifier: flow.codeVerifier,
expectedState: flow.state ?? undefined,
expectedState: state,
idTokenExpected: true,
},
);

View file

@ -4,6 +4,7 @@ import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api";
import { Client, db } from "@versia-server/kit/db";
import { OpenIdLoginFlows } from "@versia-server/kit/tables";
import { randomUUIDv7 } from "bun";
import { sign } from "hono/jwt";
import { describeRoute, validator } from "hono-openapi";
import * as client from "openid-client";
import { z } from "zod";
@ -43,8 +44,12 @@ export default apiRoute((app) => {
),
async (context) => {
// This is the Versia client's client_id, not the external OAuth provider's client_id
const { client_id, redirect_uri, scopes, state } =
context.req.valid("json");
const {
client_id,
redirect_uri,
scopes,
state: clientState,
} = context.req.valid("json");
const { issuer: issuerId } = context.req.valid("param");
const issuer = config.authentication.openid_providers.find(
@ -84,10 +89,6 @@ export default apiRoute((app) => {
code_challenge_method: "S256",
};
if (!oidcConfig.serverMetadata().supportsPKCE()) {
parameters.state = client.randomState();
}
// Store into database
const newFlow = (
await db
@ -96,7 +97,7 @@ export default apiRoute((app) => {
id: randomUUIDv7(),
codeVerifier,
state: parameters.state,
clientState: state,
clientState,
clientRedirectUri: redirect_uri,
clientScopes: scopes,
clientId: application.id,
@ -105,12 +106,22 @@ export default apiRoute((app) => {
.returning()
)[0];
const jwt = await sign(
{
flow: newFlow.id,
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes expiration
iss: config.http.base_url.toString(),
iat: Math.floor(Date.now() / 1000),
},
config.authentication.key,
);
parameters.state = jwt;
parameters.redirect_uri = `${oauthRedirectUri(
context.get("config").http.base_url,
issuerId,
)}?${new URLSearchParams({
flow: newFlow.id,
})}`;
)}`;
const redirectTo = client.buildAuthorizationUrl(
oidcConfig,

View file

@ -1,14 +0,0 @@
diff --git a/build/index.js b/build/index.js
index 8bea9f9d4413ecf2446ee5130b46e58d5ac37226..b1b9e89c1ac3b6bf6ac82fef94ccf92b55a40321 100644
--- a/build/index.js
+++ b/build/index.js
@@ -888,7 +888,8 @@ export function useIdTokenResponseType(config) {
}
function stripParams(url) {
url = new URL(url);
- url.search = '';
+ // Remove all params except user_id, link, and flow
+ url.search = new URLSearchParams([...url.searchParams].filter(([k]) => ['user_id', 'link', 'flow'].includes(k))).toString();
url.hash = '';
return url.href;
}