mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(api): ♻️ Move token endpoint to OpenID plugin, add revoke endpoint
This commit is contained in:
parent
2254c3d39c
commit
19213ec29e
|
|
@ -1,212 +0,0 @@
|
||||||
import { apiRoute, applyConfig, jsonOrForm } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "~/drizzle/db";
|
|
||||||
import { Tokens } from "~/drizzle/schema";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
ratelimits: {
|
|
||||||
duration: 60,
|
|
||||||
max: 10,
|
|
||||||
},
|
|
||||||
route: "/oauth/token",
|
|
||||||
});
|
|
||||||
|
|
||||||
export 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(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/oauth/token",
|
|
||||||
summary: "Get token",
|
|
||||||
middleware: [jsonOrForm()],
|
|
||||||
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(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Authorization error",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
error: z.string(),
|
|
||||||
error_description: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { grant_type, code, redirect_uri, client_id, client_secret } =
|
|
||||||
context.req.valid("json");
|
|
||||||
|
|
||||||
switch (grant_type) {
|
|
||||||
case "authorization_code": {
|
|
||||||
if (!code) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "invalid_request",
|
|
||||||
error_description: "Code is required",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!redirect_uri) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "invalid_request",
|
|
||||||
error_description: "Redirect URI is required",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client_id) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "invalid_request",
|
|
||||||
error_description: "Client ID is required",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the client_secret
|
|
||||||
const client = await db.query.Applications.findFirst({
|
|
||||||
where: (application, { eq }) =>
|
|
||||||
eq(application.clientId, client_id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!client || client.secret !== client_secret) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "invalid_client",
|
|
||||||
error_description: "Invalid client credentials",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await db.query.Tokens.findFirst({
|
|
||||||
where: (token, { eq, and }) =>
|
|
||||||
and(
|
|
||||||
eq(token.code, code),
|
|
||||||
eq(token.redirectUri, decodeURI(redirect_uri)),
|
|
||||||
eq(token.clientId, client_id),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "invalid_grant",
|
|
||||||
error_description: "Code not found",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalidate the code
|
|
||||||
await db
|
|
||||||
.update(Tokens)
|
|
||||||
.set({ code: null })
|
|
||||||
.where(eq(Tokens.id, token.id));
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
access_token: token.accessToken,
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: token.expiresAt
|
|
||||||
? Math.floor(
|
|
||||||
(new Date(token.expiresAt).getTime() -
|
|
||||||
Date.now()) /
|
|
||||||
1000,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
id_token: token.idToken,
|
|
||||||
refresh_token: null,
|
|
||||||
scope: token.scope,
|
|
||||||
created_at: Math.floor(
|
|
||||||
new Date(token.createdAt).getTime() / 1000,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "unsupported_grant_type",
|
|
||||||
error_description: "Unsupported grant type",
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -12,3 +12,5 @@ myPlugin.registerHandler(Hooks.Response, (req) => {
|
||||||
console.info("Request received:", req);
|
console.info("Request received:", req);
|
||||||
return req;
|
return req;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default myPlugin;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { Hooks, Plugin } from "@versia/kit";
|
import { Hooks, Plugin } from "@versia/kit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import authorizeRoute from "./routes/authorize";
|
import authorizeRoute from "./routes/authorize";
|
||||||
|
import tokenRevokeRoute from "./routes/oauth/revoke";
|
||||||
|
import tokenRoute from "./routes/oauth/token";
|
||||||
import ssoRoute from "./routes/sso";
|
import ssoRoute from "./routes/sso";
|
||||||
import ssoIdRoute from "./routes/sso/:id/index";
|
import ssoIdRoute from "./routes/sso/:id/index";
|
||||||
|
|
||||||
|
|
@ -61,13 +63,17 @@ const plugin = new Plugin(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Test hook for screenshots
|
||||||
plugin.registerHandler(Hooks.Response, (req) => {
|
plugin.registerHandler(Hooks.Response, (req) => {
|
||||||
console.info("Request received:", req);
|
console.info("Request received:", req);
|
||||||
return req;
|
return req;
|
||||||
});
|
});
|
||||||
|
|
||||||
authorizeRoute(plugin);
|
authorizeRoute(plugin);
|
||||||
ssoRoute(plugin);
|
ssoRoute(plugin);
|
||||||
ssoIdRoute(plugin);
|
ssoIdRoute(plugin);
|
||||||
|
tokenRoute(plugin);
|
||||||
|
tokenRevokeRoute(plugin);
|
||||||
|
|
||||||
export type PluginType = typeof plugin;
|
export type PluginType = typeof plugin;
|
||||||
export default plugin;
|
export default plugin;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "../../packages/plugin-kit/manifest.schema.json",
|
"$schema": "https://raw.githubusercontent.com/versia-pub/server/refs/heads/main/packages/plugin-kit/manifest.schema.json",
|
||||||
"name": "@versia/openid",
|
"name": "@versia/openid",
|
||||||
"description": "OpenID authentication.",
|
"description": "OpenID authentication.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
|
||||||
148
plugins/openid/routes/oauth/revoke.test.ts
Normal file
148
plugins/openid/routes/oauth/revoke.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/drizzle/db";
|
||||||
|
import { Applications, Tokens } from "~/drizzle/schema";
|
||||||
|
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||||
|
|
||||||
|
const { deleteUsers, users } = await getTestUsers(1);
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const redirectUri = "https://example.com/callback";
|
||||||
|
const scope = "openid profile email";
|
||||||
|
const secret = "test-secret";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const application = (
|
||||||
|
await db
|
||||||
|
.insert(Applications)
|
||||||
|
.values({
|
||||||
|
clientId: clientId,
|
||||||
|
redirectUri: redirectUri,
|
||||||
|
scopes: scope,
|
||||||
|
name: "Test Application",
|
||||||
|
secret,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
await db.insert(Tokens).values({
|
||||||
|
code: "test-code",
|
||||||
|
redirectUri: redirectUri,
|
||||||
|
clientId: clientId,
|
||||||
|
accessToken: "test-access-token",
|
||||||
|
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
tokenType: "Bearer",
|
||||||
|
scope,
|
||||||
|
userId: users[0].id,
|
||||||
|
applicationId: application.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteUsers();
|
||||||
|
await db.delete(Applications).where(eq(Applications.clientId, clientId));
|
||||||
|
await db.delete(Tokens).where(eq(Tokens.clientId, clientId));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/oauth/revoke", () => {
|
||||||
|
test("should revoke token with valid inputs", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
token: "test-access-token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for missing token", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("unauthorized_client");
|
||||||
|
expect(body.error_description).toBe(
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for invalid client credentials", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: "invalid-secret",
|
||||||
|
token: "test-access-token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("unauthorized_client");
|
||||||
|
expect(body.error_description).toBe(
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for token not found", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
token: "invalid-token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("unauthorized_client");
|
||||||
|
expect(body.error_description).toBe(
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for unauthorized client", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/revoke", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: "unauthorized-client-id",
|
||||||
|
client_secret: secret,
|
||||||
|
token: "test-access-token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("unauthorized_client");
|
||||||
|
expect(body.error_description).toBe(
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
105
plugins/openid/routes/oauth/revoke.ts
Normal file
105
plugins/openid/routes/oauth/revoke.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { jsonOrForm } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/drizzle/db";
|
||||||
|
import { Tokens } from "~/drizzle/schema";
|
||||||
|
import type { PluginType } from "../..";
|
||||||
|
|
||||||
|
export const schemas = {
|
||||||
|
json: z.object({
|
||||||
|
client_id: z.string(),
|
||||||
|
client_secret: z.string(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (plugin: PluginType) => {
|
||||||
|
plugin.registerRoute("/oauth/revoke", (app) => {
|
||||||
|
app.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/oauth/revoke",
|
||||||
|
summary: "Revoke token",
|
||||||
|
middleware: [jsonOrForm()],
|
||||||
|
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({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Authorization error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
error: z.string(),
|
||||||
|
error_description: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (context) => {
|
||||||
|
const { client_id, client_secret, token } =
|
||||||
|
context.req.valid("json");
|
||||||
|
|
||||||
|
const foundToken = await db.query.Tokens.findFirst({
|
||||||
|
where: (tokenTable, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(tokenTable.accessToken, token ?? ""),
|
||||||
|
eq(tokenTable.clientId, client_id),
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
application: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!(foundToken && token)) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unauthorized_client",
|
||||||
|
error_description:
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the client secret is correct
|
||||||
|
if (foundToken.application?.secret !== client_secret) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unauthorized_client",
|
||||||
|
error_description:
|
||||||
|
"You are not authorized to revoke this token",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(Tokens).where(eq(Tokens.accessToken, token));
|
||||||
|
|
||||||
|
return context.json({}, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
186
plugins/openid/routes/oauth/token.test.ts
Normal file
186
plugins/openid/routes/oauth/token.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/drizzle/db";
|
||||||
|
import { Applications, Tokens } from "~/drizzle/schema";
|
||||||
|
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||||
|
|
||||||
|
const { deleteUsers, users } = await getTestUsers(1);
|
||||||
|
const clientId = "test-client-id";
|
||||||
|
const redirectUri = "https://example.com/callback";
|
||||||
|
const scope = "openid profile email";
|
||||||
|
const secret = "test-secret";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await db.insert(Applications).values({
|
||||||
|
clientId: clientId,
|
||||||
|
redirectUri: redirectUri,
|
||||||
|
scopes: scope,
|
||||||
|
name: "Test Application",
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(Tokens).values({
|
||||||
|
code: "test-code",
|
||||||
|
redirectUri: redirectUri,
|
||||||
|
clientId: clientId,
|
||||||
|
accessToken: "test-access-token",
|
||||||
|
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
tokenType: "Bearer",
|
||||||
|
scope,
|
||||||
|
userId: users[0].id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteUsers();
|
||||||
|
await db.delete(Applications).where(eq(Applications.clientId, clientId));
|
||||||
|
await db.delete(Tokens).where(eq(Tokens.clientId, clientId));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/oauth/token", () => {
|
||||||
|
test("should return token with valid inputs", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "test-code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.access_token).toBe("test-access-token");
|
||||||
|
expect(body.token_type).toBe("Bearer");
|
||||||
|
expect(body.expires_in).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for missing code", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("invalid_request");
|
||||||
|
expect(body.error_description).toBe("Code is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for missing redirect_uri", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "test-code",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("invalid_request");
|
||||||
|
expect(body.error_description).toBe("Redirect URI is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for missing client_id", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "test-code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("invalid_request");
|
||||||
|
expect(body.error_description).toBe("Client ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for invalid client credentials", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "test-code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: "invalid-secret",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("invalid_client");
|
||||||
|
expect(body.error_description).toBe("Invalid client credentials");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for code not found", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "invalid-code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("invalid_grant");
|
||||||
|
expect(body.error_description).toBe("Code not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return error for unsupported grant type", async () => {
|
||||||
|
const response = await fakeRequest("/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
code: "test-code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: secret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("unsupported_grant_type");
|
||||||
|
expect(body.error_description).toBe("Unsupported grant type");
|
||||||
|
});
|
||||||
|
});
|
||||||
220
plugins/openid/routes/oauth/token.ts
Normal file
220
plugins/openid/routes/oauth/token.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { jsonOrForm } from "@/api";
|
||||||
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "~/drizzle/db";
|
||||||
|
import { Tokens } from "~/drizzle/schema";
|
||||||
|
import type { PluginType } from "../..";
|
||||||
|
|
||||||
|
export 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) => {
|
||||||
|
plugin.registerRoute("/oauth/token", (app) => {
|
||||||
|
app.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/oauth/token",
|
||||||
|
summary: "Get token",
|
||||||
|
middleware: [jsonOrForm()],
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
description: "Authorization error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
error: z.string(),
|
||||||
|
error_description: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (context) => {
|
||||||
|
const {
|
||||||
|
grant_type,
|
||||||
|
code,
|
||||||
|
redirect_uri,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
} = context.req.valid("json");
|
||||||
|
|
||||||
|
switch (grant_type) {
|
||||||
|
case "authorization_code": {
|
||||||
|
if (!code) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Code is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!redirect_uri) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description:
|
||||||
|
"Redirect URI is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client_id) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Client ID is required",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the client_secret
|
||||||
|
const client = await db.query.Applications.findFirst({
|
||||||
|
where: (application, { eq }) =>
|
||||||
|
eq(application.clientId, client_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client || client.secret !== client_secret) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_client",
|
||||||
|
error_description:
|
||||||
|
"Invalid client credentials",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await db.query.Tokens.findFirst({
|
||||||
|
where: (token, { eq, and }) =>
|
||||||
|
and(
|
||||||
|
eq(token.code, code),
|
||||||
|
eq(
|
||||||
|
token.redirectUri,
|
||||||
|
decodeURI(redirect_uri),
|
||||||
|
),
|
||||||
|
eq(token.clientId, client_id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Code not found",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the code
|
||||||
|
await db
|
||||||
|
.update(Tokens)
|
||||||
|
.set({ code: null })
|
||||||
|
.where(eq(Tokens.id, token.id));
|
||||||
|
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
access_token: token.accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: token.expiresAt
|
||||||
|
? Math.floor(
|
||||||
|
(new Date(token.expiresAt).getTime() -
|
||||||
|
Date.now()) /
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
id_token: token.idToken,
|
||||||
|
refresh_token: null,
|
||||||
|
scope: token.scope,
|
||||||
|
created_at: Math.floor(
|
||||||
|
new Date(token.createdAt).getTime() / 1000,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.json(
|
||||||
|
{
|
||||||
|
error: "unsupported_grant_type",
|
||||||
|
error_description: "Unsupported grant type",
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue