import { auth, jsonOrForm } from "@/api"; import { randomString } from "@/math"; import { z } from "@hono/zod-openapi"; import { Application, Token, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; import { type JWTPayload, SignJWT, jwtVerify } from "jose"; import { JOSEError } from "jose/errors"; 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", 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, }, }, async (context) => { const { scope, redirect_uri, client_id, state } = context.req.valid("json"); const { jwt } = context.req.valid("cookie"); const { keys } = context.get("pluginConfig"); const errorSearchParams = new URLSearchParams( context.req.valid("json"), ); const result = await jwtVerify(jwt, keys.public, { algorithms: ["EdDSA"], audience: client_id, issuer: new URL(context.get("config").http.base_url).origin, }).catch((error) => { if (error instanceof JOSEError) { return null; } throw error; }); if (!result) { return errorRedirect( context, errors.InvalidJWT, errorSearchParams, ); } const { payload: { aud, sub, exp }, } = result; if (!(aud && sub && exp)) { return errorRedirect( context, errors.MissingJWTFields, errorSearchParams, ); } if (!z.string().uuid().safeParse(sub).success) { return errorRedirect( context, errors.InvalidSub, errorSearchParams, ); } const user = await User.fromId(sub); if (!user) { return errorRedirect( context, errors.UserNotFound, errorSearchParams, ); } if (!user.hasPermission(RolePermissions.OAuth)) { return errorRedirect( context, errors.MissingOauthPermission, errorSearchParams, ); } const application = await Application.fromClientId(client_id); if (!application) { return errorRedirect( context, errors.MissingApplication, errorSearchParams, ); } if (application.data.redirectUri !== redirect_uri) { return errorRedirect( context, errors.InvalidRedirectUri, errorSearchParams, ); } // Check that scopes are a subset of the application's scopes if ( scope && !scope .split(" ") .every((s) => application.data.scopes.includes(s)) ) { return errorRedirect( context, errors.InvalidScope, errorSearchParams, ); } const code = randomString(256, "base64url"); let payload: JWTPayload = {}; if (scope) { if (scope.split(" ").includes("openid")) { payload = { ...payload, sub: user.id, iss: new URL(context.get("config").http.base_url) .origin, aud: client_id, exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000), }; } if (scope.split(" ").includes("profile")) { payload = { ...payload, name: user.data.displayName, preferred_username: user.data.username, picture: user.getAvatarUrl(), updated_at: new Date( user.data.updatedAt, ).toISOString(), }; } if (scope.split(" ").includes("email")) { payload = { ...payload, email: user.data.email, // TODO: Add verification system email_verified: true, }; } } const idToken = await new SignJWT(payload) .setProtectedHeader({ alg: "EdDSA" }) .sign(keys.private); await Token.insert({ accessToken: randomString(64, "base64url"), code, scope: scope ?? application.data.scopes, tokenType: "Bearer", applicationId: application.id, redirectUri: redirect_uri ?? application.data.redirectUri, expiresAt: new Date( Date.now() + 60 * 60 * 24 * 14, ).toISOString(), idToken: ["profile", "email", "openid"].some((s) => scope?.split(" ").includes(s), ) ? idToken : null, clientId: client_id, userId: user.id, }); const redirectUri = redirect_uri === "urn:ietf:wg:oauth:2.0:oob" ? new URL( "/oauth/code", context.get("config").http.base_url, ) : new URL(redirect_uri ?? application.data.redirectUri); redirectUri.searchParams.append("code", code); state && redirectUri.searchParams.append("state", state); return context.redirect(redirectUri.toString()); }, ), );