2025-08-21 00:45:58 +02:00
|
|
|
import { Token as TokenSchema } from "@versia/client/schemas";
|
2025-07-07 05:52:11 +02:00
|
|
|
import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api";
|
2025-08-21 01:21:32 +02:00
|
|
|
import { Client, db, Token } from "@versia-server/kit/db";
|
2025-08-21 00:45:58 +02:00
|
|
|
import { AuthorizationCodes } from "@versia-server/kit/tables";
|
|
|
|
|
import { randomUUIDv7 } from "bun";
|
2025-07-07 05:52:11 +02:00
|
|
|
import { and, eq } from "drizzle-orm";
|
|
|
|
|
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
|
|
|
import { z } from "zod/v4";
|
2025-08-21 00:45:58 +02:00
|
|
|
import { randomString } from "@/math";
|
2025-07-07 05:52:11 +02:00
|
|
|
|
|
|
|
|
export default apiRoute((app) => {
|
|
|
|
|
app.post(
|
|
|
|
|
"/oauth/token",
|
|
|
|
|
describeRoute({
|
2025-08-21 00:45:58 +02:00
|
|
|
summary: "Obtain a token",
|
|
|
|
|
description:
|
|
|
|
|
"Obtain an access token, to be used during API calls that are not public.",
|
|
|
|
|
externalDocs: {
|
|
|
|
|
url: "https://docs.joinmastodon.org/methods/oauth/#token",
|
|
|
|
|
},
|
2025-07-07 05:52:11 +02:00
|
|
|
tags: ["OpenID"],
|
|
|
|
|
responses: {
|
|
|
|
|
200: {
|
|
|
|
|
description: "Token",
|
|
|
|
|
content: {
|
|
|
|
|
"application/json": {
|
2025-08-21 00:45:58 +02:00
|
|
|
schema: resolver(TokenSchema),
|
2025-07-07 05:52:11 +02:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
401: {
|
2025-08-21 00:45:58 +02:00
|
|
|
description: "Invalid grant",
|
2025-07-07 05:52:11 +02:00
|
|
|
content: {
|
|
|
|
|
"application/json": {
|
|
|
|
|
schema: resolver(
|
|
|
|
|
z.object({
|
|
|
|
|
error: z.string(),
|
|
|
|
|
error_description: z.string(),
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
jsonOrForm(),
|
|
|
|
|
validator(
|
|
|
|
|
"json",
|
|
|
|
|
z.object({
|
2025-08-21 00:45:58 +02:00
|
|
|
code: z.string(),
|
|
|
|
|
grant_type: z.enum([
|
|
|
|
|
"authorization_code",
|
|
|
|
|
"refresh_token",
|
|
|
|
|
"client_credentials",
|
|
|
|
|
]),
|
2025-07-07 05:52:11 +02:00
|
|
|
code_verifier: z.string().optional(),
|
2025-08-21 00:45:58 +02:00
|
|
|
client_id: z.string(),
|
|
|
|
|
client_secret: z.string(),
|
|
|
|
|
redirect_uri: z.url(),
|
2025-07-07 05:52:11 +02:00
|
|
|
refresh_token: z.string().optional(),
|
2025-08-21 00:45:58 +02:00
|
|
|
scope: z.string().default("read"),
|
2025-07-07 05:52:11 +02:00
|
|
|
}),
|
|
|
|
|
handleZodError,
|
|
|
|
|
),
|
|
|
|
|
async (context) => {
|
2025-08-21 01:15:38 +02:00
|
|
|
const { code, client_id, client_secret, redirect_uri, grant_type } =
|
2025-07-07 05:52:11 +02:00
|
|
|
context.req.valid("json");
|
|
|
|
|
|
2025-08-21 01:15:38 +02:00
|
|
|
if (grant_type !== "authorization_code") {
|
|
|
|
|
return context.json(
|
|
|
|
|
{
|
|
|
|
|
error: "unsupported_grant_type",
|
|
|
|
|
error_description: "Unsupported grant type",
|
|
|
|
|
},
|
|
|
|
|
401,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
// Verify the client_secret
|
2025-08-21 01:21:32 +02:00
|
|
|
const client = await Client.fromClientId(client_id);
|
2025-07-07 05:52:11 +02:00
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
if (!client || client.data.secret !== client_secret) {
|
|
|
|
|
return context.json(
|
|
|
|
|
{
|
|
|
|
|
error: "invalid_client",
|
|
|
|
|
error_description: "Invalid client credentials",
|
|
|
|
|
},
|
|
|
|
|
401,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-07-07 05:52:11 +02:00
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
const authorizationCode =
|
|
|
|
|
await db.query.AuthorizationCodes.findFirst({
|
|
|
|
|
where: (codeTable) =>
|
2025-07-07 05:52:11 +02:00
|
|
|
and(
|
2025-08-21 00:45:58 +02:00
|
|
|
eq(codeTable.code, code),
|
|
|
|
|
eq(codeTable.redirectUri, redirect_uri),
|
|
|
|
|
eq(codeTable.clientId, client.id),
|
2025-07-07 05:52:11 +02:00
|
|
|
),
|
2025-08-21 00:45:58 +02:00
|
|
|
});
|
2025-07-07 05:52:11 +02:00
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
if (
|
|
|
|
|
!authorizationCode ||
|
|
|
|
|
new Date(authorizationCode.expiresAt).getTime() < Date.now()
|
|
|
|
|
) {
|
|
|
|
|
return context.json(
|
|
|
|
|
{
|
|
|
|
|
error: "invalid_grant",
|
|
|
|
|
error_description:
|
|
|
|
|
"Authorization code not found or expired",
|
|
|
|
|
},
|
|
|
|
|
404,
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-07-07 05:52:11 +02:00
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
const token = await Token.insert({
|
|
|
|
|
accessToken: randomString(64, "base64url"),
|
|
|
|
|
clientId: client.id,
|
|
|
|
|
id: randomUUIDv7(),
|
|
|
|
|
userId: authorizationCode.userId,
|
2025-08-21 01:15:38 +02:00
|
|
|
expiresAt: null,
|
2025-08-21 00:45:58 +02:00
|
|
|
});
|
2025-07-07 05:52:11 +02:00
|
|
|
|
2025-08-21 00:45:58 +02:00
|
|
|
// Invalidate the code
|
|
|
|
|
await db
|
|
|
|
|
.delete(AuthorizationCodes)
|
|
|
|
|
.where(eq(AuthorizationCodes.code, authorizationCode.code));
|
2025-07-07 05:52:11 +02:00
|
|
|
|
|
|
|
|
return context.json(
|
|
|
|
|
{
|
2025-08-21 00:45:58 +02:00
|
|
|
...token.toApi(),
|
|
|
|
|
expires_in: token.data.expiresAt
|
|
|
|
|
? Math.floor(
|
|
|
|
|
(new Date(token.data.expiresAt).getTime() -
|
|
|
|
|
Date.now()) /
|
|
|
|
|
1000,
|
|
|
|
|
)
|
|
|
|
|
: null,
|
|
|
|
|
refresh_token: null,
|
2025-07-07 05:52:11 +02:00
|
|
|
},
|
2025-08-21 00:45:58 +02:00
|
|
|
200,
|
2025-07-07 05:52:11 +02:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
});
|