diff --git a/api/oauth/token/index.ts b/api/oauth/token/index.ts deleted file mode 100644 index be8b08ce..00000000 --- a/api/oauth/token/index.ts +++ /dev/null @@ -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, - ); - }), -); diff --git a/packages/plugin-kit/example.ts b/packages/plugin-kit/example.ts index 2b692157..4315475c 100644 --- a/packages/plugin-kit/example.ts +++ b/packages/plugin-kit/example.ts @@ -12,3 +12,5 @@ myPlugin.registerHandler(Hooks.Response, (req) => { console.info("Request received:", req); return req; }); + +export default myPlugin; diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts index 96fbbe03..1f7dce8f 100644 --- a/plugins/openid/index.ts +++ b/plugins/openid/index.ts @@ -1,6 +1,8 @@ import { Hooks, Plugin } from "@versia/kit"; import { z } from "zod"; import authorizeRoute from "./routes/authorize"; +import tokenRevokeRoute from "./routes/oauth/revoke"; +import tokenRoute from "./routes/oauth/token"; import ssoRoute from "./routes/sso"; import ssoIdRoute from "./routes/sso/:id/index"; @@ -61,13 +63,17 @@ const plugin = new Plugin( }), ); +// Test hook for screenshots plugin.registerHandler(Hooks.Response, (req) => { console.info("Request received:", req); return req; }); + authorizeRoute(plugin); ssoRoute(plugin); ssoIdRoute(plugin); +tokenRoute(plugin); +tokenRevokeRoute(plugin); export type PluginType = typeof plugin; export default plugin; diff --git a/plugins/openid/manifest.json b/plugins/openid/manifest.json index e0b856a7..e6ac5dff 100644 --- a/plugins/openid/manifest.json +++ b/plugins/openid/manifest.json @@ -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", "description": "OpenID authentication.", "version": "0.1.0", diff --git a/plugins/openid/routes/oauth/revoke.test.ts b/plugins/openid/routes/oauth/revoke.test.ts new file mode 100644 index 00000000..2fd53309 --- /dev/null +++ b/plugins/openid/routes/oauth/revoke.test.ts @@ -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", + ); + }); +}); diff --git a/plugins/openid/routes/oauth/revoke.ts b/plugins/openid/routes/oauth/revoke.ts new file mode 100644 index 00000000..6bdae6dc --- /dev/null +++ b/plugins/openid/routes/oauth/revoke.ts @@ -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); + }, + ); + }); +}; diff --git a/plugins/openid/routes/oauth/token.test.ts b/plugins/openid/routes/oauth/token.test.ts new file mode 100644 index 00000000..ec1c1dc4 --- /dev/null +++ b/plugins/openid/routes/oauth/token.test.ts @@ -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"); + }); +}); diff --git a/plugins/openid/routes/oauth/token.ts b/plugins/openid/routes/oauth/token.ts new file mode 100644 index 00000000..63ddb380 --- /dev/null +++ b/plugins/openid/routes/oauth/token.ts @@ -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, + ); + }, + ); + }); +};