mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(plugin): ♻️ Move parts of OpenID logic to plugin
This commit is contained in:
parent
69d7d50239
commit
d51bae52c6
17 changed files with 494 additions and 395 deletions
309
plugins/openid/routes/authorize.ts
Normal file
309
plugins/openid/routes/authorize.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { auth, jsonOrForm } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { type JWTPayload, SignJWT, jwtVerify } from "jose";
|
||||
import { JOSEError } from "jose/errors";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~/classes/functions/token";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions, Tokens } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
import type { PluginType } from "../index";
|
||||
|
||||
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) =>
|
||||
plugin.registerRoute("/oauth/authorize", (app) =>
|
||||
app.openapi(
|
||||
{
|
||||
method: "post",
|
||||
path: "/oauth/authorize",
|
||||
middleware: [
|
||||
auth({
|
||||
required: false,
|
||||
}),
|
||||
jsonOrForm(),
|
||||
plugin.middleware,
|
||||
],
|
||||
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(
|
||||
Object.fromEntries(
|
||||
Object.entries(context.req.valid("json")).filter(
|
||||
([k, v]) =>
|
||||
v !== undefined &&
|
||||
k !== "password" &&
|
||||
k !== "email",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, could not verify",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
payload: { aud, sub, exp },
|
||||
} = result;
|
||||
|
||||
if (!(aud && sub && exp)) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, missing required fields (aud, sub, exp)",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.fromId(sub);
|
||||
|
||||
if (!user) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, could not find associated user",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.hasPermission(RolePermissions.OAuth)) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
`User is missing the required permission ${RolePermissions.OAuth}`,
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await db.query.Applications.findFirst({
|
||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||
});
|
||||
|
||||
if (!application) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid client_id: no associated application found",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (application.redirectUri !== redirect_uri) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid redirect_uri: does not match application's redirect_uri",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check that scopes are a subset of the application's scopes
|
||||
if (
|
||||
scope &&
|
||||
!scope
|
||||
.split(" ")
|
||||
.every((s) => application.scopes.includes(s))
|
||||
) {
|
||||
errorSearchParams.append("error", "invalid_scope");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid scope: not a subset of the application's scopes",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
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(context.get("config")),
|
||||
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 db.insert(Tokens).values({
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: code,
|
||||
scope: scope ?? application.scopes,
|
||||
tokenType: TokenType.Bearer,
|
||||
applicationId: application.id,
|
||||
redirectUri: redirect_uri ?? application.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.redirectUri);
|
||||
|
||||
redirectUri.searchParams.append("code", code);
|
||||
state && redirectUri.searchParams.append("state", state);
|
||||
|
||||
return context.redirect(redirectUri.toString());
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue