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", "msgpackr-extract",
"protobufjs", "protobufjs",
], ],
"patchedDependencies": {
"openid-client@6.8.1": "patches/openid-client@6.8.1.patch",
},
"catalog": { "catalog": {
"@biomejs/biome": "2.3.4", "@biomejs/biome": "2.3.4",
"@bull-board/api": "~6.14.2", "@bull-board/api": "~6.14.2",

View file

@ -215,8 +215,5 @@
"zod": "catalog:", "zod": "catalog:",
"zod-openapi": "catalog:", "zod-openapi": "catalog:",
"zod-validation-error": "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 { Client, db } from "@versia-server/kit/db";
import { OpenIdLoginFlows } from "@versia-server/kit/tables"; import { OpenIdLoginFlows } from "@versia-server/kit/tables";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { sign } from "hono/jwt";
import { describeRoute, resolver, validator } from "hono-openapi"; import { describeRoute, resolver, validator } from "hono-openapi";
import * as client from "openid-client"; import * as client from "openid-client";
import { z } from "zod"; import { z } from "zod";
@ -114,10 +115,6 @@ export default apiRoute((app) => {
code_challenge_method: "S256", code_challenge_method: "S256",
}; };
if (!oidcConfig.serverMetadata().supportsPKCE()) {
parameters.state = client.randomState();
}
const redirectUri = oauthRedirectUri( const redirectUri = oauthRedirectUri(
context.get("config").http.base_url, context.get("config").http.base_url,
issuerId, issuerId,
@ -149,14 +146,24 @@ export default apiRoute((app) => {
.returning() .returning()
)[0]; )[0];
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( parameters.redirect_uri = `${oauthRedirectUri(
config.http.base_url, config.http.base_url,
issuerId, issuerId,
)}?${new URLSearchParams({ )}`;
flow: newFlow.id,
link: "true",
user_id: user.id,
})}`;
const redirectTo = client.buildAuthorizationUrl( const redirectTo = client.buildAuthorizationUrl(
oidcConfig, oidcConfig,

View file

@ -1,7 +1,6 @@
import { import {
Account as AccountSchema, Account as AccountSchema,
RolePermission, RolePermission,
zBoolean,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
@ -16,7 +15,7 @@ import {
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { and, eq, isNull, type SQL } from "drizzle-orm"; import { and, eq, isNull, type SQL } from "drizzle-orm";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { sign } from "hono/jwt"; import { sign, verify } from "hono/jwt";
import { describeRoute, validator } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import * as client from "openid-client"; import * as client from "openid-client";
import { z } from "zod"; import { z } from "zod";
@ -48,15 +47,13 @@ export default apiRoute((app) => {
validator( validator(
"query", "query",
z.object({ z.object({
flow: z.string(), state: z.string(),
link: zBoolean.default(false),
user_id: z.uuid().optional(),
}), }),
handleZodError, handleZodError,
), ),
async (context) => { async (context) => {
const { issuer: issuerId } = context.req.valid("param"); 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( const issuer = config.authentication.openid_providers.find(
(provider) => provider.id === issuerId, (provider) => provider.id === issuerId,
@ -66,6 +63,16 @@ export default apiRoute((app) => {
throw new ApiError(422, "Unknown or invalid issuer"); 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({ const flow = await db.query.OpenIdLoginFlows.findFirst({
where: (flow): SQL | undefined => eq(flow.id, flowId), where: (flow): SQL | undefined => eq(flow.id, flowId),
with: { with: {
@ -104,7 +111,7 @@ export default apiRoute((app) => {
context.req.raw, context.req.raw,
{ {
pkceCodeVerifier: flow.codeVerifier, pkceCodeVerifier: flow.codeVerifier,
expectedState: flow.state ?? undefined, expectedState: state,
idTokenExpected: true, 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 { Client, db } from "@versia-server/kit/db";
import { OpenIdLoginFlows } from "@versia-server/kit/tables"; import { OpenIdLoginFlows } from "@versia-server/kit/tables";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { sign } from "hono/jwt";
import { describeRoute, validator } from "hono-openapi"; import { describeRoute, validator } from "hono-openapi";
import * as client from "openid-client"; import * as client from "openid-client";
import { z } from "zod"; import { z } from "zod";
@ -43,8 +44,12 @@ export default apiRoute((app) => {
), ),
async (context) => { async (context) => {
// This is the Versia client's client_id, not the external OAuth provider's client_id // This is the Versia client's client_id, not the external OAuth provider's client_id
const { client_id, redirect_uri, scopes, state } = const {
context.req.valid("json"); client_id,
redirect_uri,
scopes,
state: clientState,
} = context.req.valid("json");
const { issuer: issuerId } = context.req.valid("param"); const { issuer: issuerId } = context.req.valid("param");
const issuer = config.authentication.openid_providers.find( const issuer = config.authentication.openid_providers.find(
@ -84,10 +89,6 @@ export default apiRoute((app) => {
code_challenge_method: "S256", code_challenge_method: "S256",
}; };
if (!oidcConfig.serverMetadata().supportsPKCE()) {
parameters.state = client.randomState();
}
// Store into database // Store into database
const newFlow = ( const newFlow = (
await db await db
@ -96,7 +97,7 @@ export default apiRoute((app) => {
id: randomUUIDv7(), id: randomUUIDv7(),
codeVerifier, codeVerifier,
state: parameters.state, state: parameters.state,
clientState: state, clientState,
clientRedirectUri: redirect_uri, clientRedirectUri: redirect_uri,
clientScopes: scopes, clientScopes: scopes,
clientId: application.id, clientId: application.id,
@ -105,12 +106,22 @@ export default apiRoute((app) => {
.returning() .returning()
)[0]; )[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( parameters.redirect_uri = `${oauthRedirectUri(
context.get("config").http.base_url, context.get("config").http.base_url,
issuerId, issuerId,
)}?${new URLSearchParams({ )}`;
flow: newFlow.id,
})}`;
const redirectTo = client.buildAuthorizationUrl( const redirectTo = client.buildAuthorizationUrl(
oidcConfig, 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;
}