refactor(api): ♻️ Move from @hono/zod-openapi to hono-openapi

hono-openapi is easier to work with and generates better OpenAPI definitions
This commit is contained in:
Jesse Wierzbinski 2025-03-29 03:30:06 +01:00
parent 0576aff972
commit 58342e86e1
No known key found for this signature in database
240 changed files with 9494 additions and 9575 deletions

View file

@ -1,105 +1,101 @@
import { auth, jsonOrForm } from "@/api";
import { auth, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math";
import { z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { Application, Token, User } from "@versia/kit/db";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { type JWTPayload, SignJWT, jwtVerify } from "jose";
import { JOSEError } from "jose/errors";
import { z } from "zod";
import { errorRedirect, errors } from "../errors.ts";
import type { PluginType } from "../index.ts";
const schemas = {
query: z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z.coerce
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
json: z
.object({
scope: z.string().optional(),
redirect_uri: z
.string()
.url()
.optional()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
})
.refine(
// Check if redirect_uri is valid for code flow
(data) =>
data.response_type.includes("code") ? data.redirect_uri : true,
"redirect_uri is required for code flow",
),
// Disable for Mastodon API compatibility
/* .refine(
// Check if code_challenge is valid for code flow
(data) =>
data.response_type.includes("code")
? data.code_challenge
: true,
"code_challenge is required for code flow",
), */
cookies: z.object({
jwt: z.string(),
}),
};
export default (plugin: PluginType): void =>
plugin.registerRoute("/oauth/authorize", (app) =>
app.openapi(
{
method: "post",
path: "/oauth/authorize",
app.post(
"/oauth/authorize",
describeRoute({
summary: "Main OpenID authorization endpoint",
tags: ["OpenID"],
middleware: [
auth({
auth: false,
}),
jsonOrForm(),
plugin.middleware,
] as const,
responses: {
302: {
description: "Redirect to the application",
},
},
request: {
query: schemas.query,
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
cookies: schemas.cookies,
},
},
}),
plugin.middleware,
auth({
auth: false,
}),
jsonOrForm(),
validator(
"query",
z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z.coerce
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
handleZodError,
),
validator(
"json",
z
.object({
scope: z.string().optional(),
redirect_uri: z
.string()
.url()
.optional()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z
.enum(["plain", "S256"])
.optional(),
})
.refine(
// Check if redirect_uri is valid for code flow
(data) =>
data.response_type.includes("code")
? data.redirect_uri
: true,
"redirect_uri is required for code flow",
),
// Disable for Mastodon API compatibility
/* .refine(
// Check if code_challenge is valid for code flow
(data) =>
data.response_type.includes("code")
? data.code_challenge
: true,
"code_challenge is required for code flow",
), */
handleZodError,
),
validator(
"cookie",
z.object({
jwt: z.string(),
}),
handleZodError,
),
async (context) => {
const { scope, redirect_uri, client_id, state } =
context.req.valid("json");

View file

@ -1,14 +1,15 @@
import { auth } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { exportJWK } from "jose";
import { z } from "zod";
import type { PluginType } from "../index.ts";
export default (plugin: PluginType): void => {
plugin.registerRoute("/.well-known/jwks", (app) =>
app.openapi(
createRoute({
method: "get",
path: "/.well-known/jwks",
app.get(
"/.well-known/jwks",
describeRoute({
summary: "JWK Set",
tags: ["OpenID"],
responses: {
@ -16,30 +17,30 @@ export default (plugin: PluginType): void => {
description: "JWK Set",
content: {
"application/json": {
schema: z.object({
keys: z.array(
z.object({
kty: z.string().optional(),
use: z.string(),
alg: z.string(),
kid: z.string(),
crv: z.string().optional(),
x: z.string().optional(),
y: z.string().optional(),
}),
),
}),
schema: resolver(
z.object({
keys: z.array(
z.object({
kty: z.string().optional(),
use: z.string(),
alg: z.string(),
kid: z.string(),
crv: z.string().optional(),
x: z.string().optional(),
y: z.string().optional(),
}),
),
}),
),
},
},
},
},
middleware: [
auth({
auth: false,
}),
plugin.middleware,
] as const,
}),
auth({
auth: false,
}),
plugin.middleware,
async (context) => {
const jwk = await exportJWK(
context.get("pluginConfig").keys?.public,

View file

@ -1,46 +1,28 @@
import { handleZodError } from "@/api";
import { randomString } from "@/math.ts";
import { createRoute, z } from "@hono/zod-openapi";
import { Account as AccountSchema } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas";
import { Media, Token, User, db } from "@versia/kit/db";
import { type SQL, and, eq, isNull } from "@versia/kit/drizzle";
import { OpenIdAccounts, Users } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import { setCookie } from "hono/cookie";
import { SignJWT } from "jose";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts";
import { automaticOidcFlow } from "../../utils.ts";
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): void => {
plugin.registerRoute("/oauth/sso/{issuer}/callback", (app) => {
app.openapi(
createRoute({
method: "get",
path: "/oauth/sso/{issuer}/callback",
app.get(
"/oauth/sso/:issuer/callback",
describeRoute({
summary: "SSO callback",
tags: ["OpenID"],
description:
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
middleware: [plugin.middleware] as const,
request: {
query: schemas.query,
params: schemas.param,
},
responses: {
302: {
description:
@ -48,6 +30,29 @@ export default (plugin: PluginType): void => {
},
},
}),
plugin.middleware,
validator(
"param",
z.object({
issuer: z.string(),
}),
handleZodError,
),
validator(
"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(),
}),
handleZodError,
),
async (context) => {
const currentUrl = new URL(context.req.url);
const redirectUrl = new URL(context.req.url);

View file

@ -1,48 +1,25 @@
import { jsonOrForm } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { handleZodError, jsonOrForm } from "@/api";
import { Token, db } from "@versia/kit/db";
import { and, eq } from "@versia/kit/drizzle";
import { Tokens } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import type { PluginType } from "../../index.ts";
const schemas = {
json: z.object({
client_id: z.string(),
client_secret: z.string(),
token: z.string().optional(),
}),
};
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/revoke", (app) => {
app.openapi(
createRoute({
method: "post",
path: "/oauth/revoke",
app.post(
"/oauth/revoke",
describeRoute({
summary: "Revoke token",
tags: ["OpenID"],
middleware: [jsonOrForm(), plugin.middleware],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Token deleted",
content: {
"application/json": {
schema: z.object({}),
schema: resolver(z.object({})),
},
},
},
@ -50,15 +27,28 @@ export default (plugin: PluginType): void => {
description: "Authorization error",
content: {
"application/json": {
schema: z.object({
error: z.string(),
error_description: z.string(),
}),
schema: resolver(
z.object({
error: z.string(),
error_description: z.string(),
}),
),
},
},
},
},
}),
jsonOrForm(),
plugin.middleware,
validator(
"json",
z.object({
client_id: z.string(),
client_secret: z.string(),
token: z.string().optional(),
}),
handleZodError,
),
async (context) => {
const { client_id, client_secret, token } =
context.req.valid("json");

View file

@ -1,37 +1,25 @@
import { createRoute, z } from "@hono/zod-openapi";
import { handleZodError } from "@/api.ts";
import { Application, db } from "@versia/kit/db";
import { OpenIdLoginFlows } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod";
import {
calculatePKCECodeChallenge,
discoveryRequest,
generateRandomCodeVerifier,
processDiscoveryResponse,
} from "oauth4webapi";
import { z } from "zod";
import type { PluginType } from "../../index.ts";
import { oauthRedirectUri } from "../../utils.ts";
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): void => {
plugin.registerRoute("/oauth/sso", (app) => {
app.openapi(
createRoute({
method: "get",
path: "/oauth/sso",
app.get(
"/oauth/sso",
describeRoute({
summary: "Initiate SSO login flow",
tags: ["OpenID"],
request: {
query: schemas.query,
},
middleware: [plugin.middleware] as const,
responses: {
302: {
description:
@ -39,6 +27,18 @@ export default (plugin: PluginType): void => {
},
},
}),
plugin.middleware,
validator(
"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(),
}),
handleZodError,
),
async (context) => {
// This is the Versia client's client_id, not the external OAuth provider's client_id
const { issuer: issuerId, client_id } =

View file

@ -1,87 +1,44 @@
import { jsonOrForm } from "@/api";
import { createRoute, z } from "@hono/zod-openapi";
import { handleZodError, jsonOrForm } from "@/api";
import { Application, Token } from "@versia/kit/db";
import { and, eq } from "@versia/kit/drizzle";
import { Tokens } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import type { PluginType } from "../../index.ts";
const schemas = {
json: z.object({
code: z.string().optional(),
code_verifier: z.string().optional(),
grant_type: z
.enum([
"authorization_code",
"refresh_token",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:saml2-bearer",
"urn:openid:params:grant-type:ciba",
])
.default("authorization_code"),
client_id: z.string().optional(),
client_secret: z.string().optional(),
username: z.string().trim().optional(),
password: z.string().trim().optional(),
redirect_uri: z.string().url().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
assertion: z.string().optional(),
audience: z.string().optional(),
subject_token_type: z.string().optional(),
subject_token: z.string().optional(),
actor_token_type: z.string().optional(),
actor_token: z.string().optional(),
auth_req_id: z.string().optional(),
}),
};
export default (plugin: PluginType): void => {
plugin.registerRoute("/oauth/token", (app) => {
app.openapi(
createRoute({
method: "post",
path: "/oauth/token",
app.post(
"/oauth/token",
describeRoute({
summary: "Get token",
tags: ["OpenID"],
middleware: [jsonOrForm(), plugin.middleware],
request: {
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
},
responses: {
200: {
description: "Token",
content: {
"application/json": {
schema: z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z
.number()
.optional()
.nullable(),
id_token: z.string().optional().nullable(),
refresh_token: z
.string()
.optional()
.nullable(),
scope: z.string().optional(),
created_at: z.number(),
}),
schema: resolver(
z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z
.number()
.optional()
.nullable(),
id_token: z
.string()
.optional()
.nullable(),
refresh_token: z
.string()
.optional()
.nullable(),
scope: z.string().optional(),
created_at: z.number(),
}),
),
},
},
},
@ -89,15 +46,53 @@ export default (plugin: PluginType): void => {
description: "Authorization error",
content: {
"application/json": {
schema: z.object({
error: z.string(),
error_description: z.string(),
}),
schema: resolver(
z.object({
error: z.string(),
error_description: z.string(),
}),
),
},
},
},
},
}),
jsonOrForm(),
plugin.middleware,
validator(
"json",
z.object({
code: z.string().optional(),
code_verifier: z.string().optional(),
grant_type: z
.enum([
"authorization_code",
"refresh_token",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:saml2-bearer",
"urn:openid:params:grant-type:ciba",
])
.default("authorization_code"),
client_id: z.string().optional(),
client_secret: z.string().optional(),
username: z.string().trim().optional(),
password: z.string().trim().optional(),
redirect_uri: z.string().url().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
assertion: z.string().optional(),
audience: z.string().optional(),
subject_token_type: z.string().optional(),
subject_token: z.string().optional(),
actor_token_type: z.string().optional(),
actor_token: z.string().optional(),
auth_req_id: z.string().optional(),
}),
handleZodError,
),
async (context) => {
const {
grant_type,

View file

@ -1,49 +1,46 @@
import { auth } from "@/api";
import { auth, handleZodError } from "@/api";
import { proxyUrl } from "@/response";
import { createRoute, z } from "@hono/zod-openapi";
import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db";
import { type SQL, eq } from "@versia/kit/drizzle";
import { OpenIdAccounts } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import type { PluginType } from "~/plugins/openid";
export default (plugin: PluginType): void => {
plugin.registerRoute("/api/v1/sso", (app) => {
app.openapi(
createRoute({
method: "get",
path: "/api/v1/sso/{id}",
plugin.registerRoute("/api/v1/sso/{id}", (app) => {
app.get(
"/api/v1/sso/:id",
describeRoute({
summary: "Get linked account",
tags: ["SSO"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
] as const,
request: {
params: z.object({
id: z.string(),
}),
},
responses: {
200: {
description: "Linked account",
content: {
"application/json": {
schema: z.object({
id: z.string(),
name: z.string(),
icon: z.string().optional(),
}),
schema: resolver(
z.object({
id: z.string(),
name: z.string(),
icon: z.string().optional(),
}),
),
},
},
},
404: ApiError.accountNotFound().schema,
},
}),
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
validator("param", z.object({ id: z.string() }), handleZodError),
async (context) => {
const { id: issuerId } = context.req.valid("param");
const { user } = context.get("auth");
@ -89,24 +86,11 @@ export default (plugin: PluginType): void => {
},
);
app.openapi(
createRoute({
method: "delete",
path: "/api/v1/sso/{id}",
app.delete(
"/api/v1/sso/:id",
describeRoute({
summary: "Unlink account",
tags: ["SSO"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
] as const,
request: {
params: z.object({
id: z.string(),
}),
},
responses: {
204: {
description: "Account unlinked",
@ -115,12 +99,18 @@ export default (plugin: PluginType): void => {
description: "Account not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
validator("param", z.object({ id: z.string() }), handleZodError),
async (context) => {
const { id: issuerId } = context.req.valid("param");
const { user } = context.get("auth");

View file

@ -1,48 +1,49 @@
import { auth } from "@/api";
import { z } from "@hono/zod-openapi";
import { auth, handleZodError } from "@/api";
import { RolePermission } from "@versia/client/schemas";
import { Application, db } from "@versia/kit/db";
import { OpenIdLoginFlows } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import {
calculatePKCECodeChallenge,
generateRandomCodeVerifier,
} from "oauth4webapi";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error.ts";
import type { PluginType } from "../../index.ts";
import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts";
export default (plugin: PluginType): void => {
plugin.registerRoute("/api/v1/sso", (app) => {
app.openapi(
{
method: "get",
path: "/api/v1/sso",
app.get(
"/api/v1/sso",
describeRoute({
summary: "Get linked accounts",
tags: ["SSO"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
] as const,
responses: {
200: {
description: "Linked accounts",
content: {
"application/json": {
schema: z.array(
z.object({
id: z.string(),
name: z.string(),
icon: z.string().optional(),
}),
schema: resolver(
z.array(
z.object({
id: z.string(),
name: z.string(),
icon: z.string().optional(),
}),
),
),
},
},
},
},
},
}),
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
async (context) => {
const { user } = context.get("auth");
@ -61,30 +62,11 @@ export default (plugin: PluginType): void => {
},
);
app.openapi(
{
method: "post",
path: "/api/v1/sso",
app.post(
"/api/v1/sso",
describeRoute({
summary: "Link account",
tags: ["SSO"],
middleware: [
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
] as const,
request: {
body: {
content: {
"application/json": {
schema: z.object({
issuer: z.string(),
}),
},
},
},
},
responses: {
302: {
description: "Redirect to OpenID provider",
@ -93,12 +75,18 @@ export default (plugin: PluginType): void => {
description: "Issuer not found",
content: {
"application/json": {
schema: ApiError.zodSchema,
schema: resolver(ApiError.zodSchema),
},
},
},
},
},
}),
auth({
auth: true,
permissions: [RolePermission.OAuth],
}),
plugin.middleware,
validator("json", z.object({ issuer: z.string() }), handleZodError),
async (context) => {
const { user } = context.get("auth");