refactor(api): ♻️ Move token endpoint to OpenID plugin, add revoke endpoint

This commit is contained in:
Jesse Wierzbinski 2024-09-30 13:42:12 +02:00
parent 2254c3d39c
commit 19213ec29e
No known key found for this signature in database
8 changed files with 668 additions and 213 deletions

View file

@ -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,
);
}),
);

View file

@ -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;

View file

@ -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;

View file

@ -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",

View 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",
);
});
});

View 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);
},
);
});
};

View 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");
});
});

View 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,
);
},
);
});
};