From 1bfc5fb013f27254f825ade738e64515fa51af2f Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 21 Aug 2025 00:45:58 +0200 Subject: [PATCH] refactor(api): :recycle: Rewrite full authentication code to go OpenID-only --- bun.lock | 14 +- cli/user/token.ts | 19 +- drizzle.config.ts | 14 +- package.json | 5 +- packages/api/package.json | 5 +- packages/api/plugins/openid/errors.ts | 45 - packages/api/plugins/openid/utils.ts | 217 -- .../api/routes/api/auth/login/index.test.ts | 26 +- packages/api/routes/api/auth/login/index.ts | 23 +- .../api/routes/api/auth/redirect/index.ts | 75 - .../api/routes/api/auth/reset/index.test.ts | 124 - packages/api/routes/api/auth/reset/index.ts | 80 - .../api/routes/api/oauth/authorize.test.ts | 394 --- packages/api/routes/api/oauth/authorize.ts | 277 -- packages/api/routes/api/oauth/revoke.test.ts | 21 +- packages/api/routes/api/oauth/revoke.ts | 2 +- packages/api/routes/api/oauth/sso.ts | 130 - .../routes/api/oauth/sso/[issuer]/callback.ts | 251 +- .../routes/api/oauth/sso/[issuer]/index.ts | 122 + packages/api/routes/api/oauth/token.test.ts | 35 +- packages/api/routes/api/oauth/token.ts | 216 +- packages/api/routes/api/v1/apps/index.ts | 12 +- packages/api/routes/api/v1/sso/index.ts | 66 +- packages/api/routes/well-known/jwks.test.ts | 35 - packages/api/routes/well-known/jwks.ts | 62 - .../well-known/openid-configuration/index.ts | 65 - packages/config/index.ts | 2 +- packages/kit/api.ts | 4 +- packages/kit/db/application.ts | 54 +- packages/kit/db/index.ts | 2 +- packages/kit/db/token.ts | 8 +- packages/kit/db/user.ts | 9 +- .../tables/migrations/0051_stiff_morbius.sql | 46 + .../tables/migrations/meta/0051_snapshot.json | 2439 +++++++++++++++++ .../kit/tables/migrations/meta/_journal.json | 7 + packages/kit/tables/schema.ts | 108 +- packages/tests/index.ts | 37 +- utils/bull-board.ts | 31 +- utils/lib.ts | 3 + 39 files changed, 3076 insertions(+), 2009 deletions(-) delete mode 100644 packages/api/plugins/openid/errors.ts delete mode 100644 packages/api/plugins/openid/utils.ts delete mode 100644 packages/api/routes/api/auth/redirect/index.ts delete mode 100644 packages/api/routes/api/auth/reset/index.test.ts delete mode 100644 packages/api/routes/api/auth/reset/index.ts delete mode 100644 packages/api/routes/api/oauth/authorize.test.ts delete mode 100644 packages/api/routes/api/oauth/authorize.ts delete mode 100644 packages/api/routes/api/oauth/sso.ts create mode 100644 packages/api/routes/api/oauth/sso/[issuer]/index.ts delete mode 100644 packages/api/routes/well-known/jwks.test.ts delete mode 100644 packages/api/routes/well-known/jwks.ts delete mode 100644 packages/api/routes/well-known/openid-configuration/index.ts create mode 100644 packages/kit/tables/migrations/0051_stiff_morbius.sql create mode 100644 packages/kit/tables/migrations/meta/0051_snapshot.json diff --git a/bun.lock b/bun.lock index f4c396b7..0ecacda7 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,6 @@ "ioredis": "catalog:", "ip-matching": "catalog:", "iso-639-1": "catalog:", - "jose": "catalog:", "linkify-html": "catalog:", "linkify-string": "catalog:", "linkifyjs": "catalog:", @@ -51,7 +50,6 @@ "markdown-it-toc-done-right": "catalog:", "mime-types": "catalog:", "mitata": "catalog:", - "oauth4webapi": "catalog:", "ora": "catalog:", "qs": "catalog:", "sharp": "catalog:", @@ -108,8 +106,7 @@ "hono-rate-limiter": "catalog:", "ip-matching": "catalog:", "iso-639-1": "catalog:", - "jose": "catalog:", - "oauth4webapi": "catalog:", + "openid-client": "catalog:", "qs": "catalog:", "sharp": "catalog:", "string-comparison": "catalog:", @@ -270,7 +267,6 @@ "ioredis": "^5.6.1", "ip-matching": "^2.1.2", "iso-639-1": "^3.1.5", - "jose": "^6.0.11", "linkify-html": "^4.3.1", "linkify-string": "^4.3.1", "linkifyjs": "^4.3.1", @@ -284,7 +280,7 @@ "mime-types": "^3.0.1", "mitata": "^1.0.34", "mitt": "^3.0.1", - "oauth4webapi": "^3.5.5", + "openid-client": "^6.6.3", "ora": "^8.2.0", "qs": "^6.14.0", "sharp": "^0.34.2", @@ -1133,7 +1129,7 @@ "jake": ["jake@10.9.2", "", { "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", "filelist": "^1.0.4", "minimatch": "^3.1.2" }, "bin": { "jake": "bin/cli.js" } }, "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA=="], - "jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="], + "jose": ["jose@6.0.12", "", {}, "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1277,7 +1273,7 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "oauth4webapi": ["oauth4webapi@3.5.5", "", {}, "sha512-1K88D2GiAydGblHo39NBro5TebGXa+7tYoyIbxvqv3+haDDry7CBE1eSYuNbOSsYCCU6y0gdynVZAkm4YPw4hg=="], + "oauth4webapi": ["oauth4webapi@3.6.2", "", {}, "sha512-hwWLiyBYuqhVdcIUJMJVKdEvz+DCweOcbSfqDyIv9PuUwrNfqrzfHP2bypZgZdbYOS67QYqnAnvZa2BJwBBrHw=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1287,6 +1283,8 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + "openid-client": ["openid-client@6.6.3", "", { "dependencies": { "jose": "^6.0.12", "oauth4webapi": "^3.6.1" } }, "sha512-sYYFJsyN21bjf/QepIU/t6w22tEUT+rYVPf1VZOSQwC+s1hAkyZpvAbFNLMrnrYMS/H74MctEHna2jPLvWbkCA=="], + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], diff --git a/cli/user/token.ts b/cli/user/token.ts index 6c88327c..d65f364c 100644 --- a/cli/user/token.ts +++ b/cli/user/token.ts @@ -1,4 +1,4 @@ -import { Token } from "@versia-server/kit/db"; +import { Application, Token } from "@versia-server/kit/db"; import { randomUUIDv7 } from "bun"; import chalk from "chalk"; // @ts-expect-error - Root import is required or the Clec type definitions won't work @@ -22,13 +22,24 @@ export const generateTokenCommand = defineCommand( throw new Error(`User ${chalk.gray(username)} not found.`); } + const application = await Application.insert({ + id: + user.id + + Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("base64"), + name: "Versia", + redirectUris: [], + scopes: ["openid", "profile", "email"], + secret: "", + }); + const token = await Token.insert({ id: randomUUIDv7(), accessToken: randomString(64, "base64url"), - code: null, - scope: "read write follow", - tokenType: "Bearer", + scopes: ["read", "write", "follow"], userId: user.id, + clientId: application.id, }); console.info( diff --git a/drizzle.config.ts b/drizzle.config.ts index 49d2ec86..faa519fe 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,4 @@ -import { config } from "@versia-server/config"; +//import { config } from "@versia-server/config"; import type { Config } from "drizzle-kit"; /** @@ -7,19 +7,19 @@ import type { Config } from "drizzle-kit"; */ export default { dialect: "postgresql", - out: "./drizzle/migrations", - schema: "./drizzle/schema.ts", + out: "./packages/kit/tables/migrations", + schema: "./packages/kit/tables/schema.ts", dbCredentials: { - /* host: "localhost", + host: "localhost", port: 40000, user: "lysand", password: "lysand", - database: "lysand", */ - host: config.postgres.host, + database: "lysand", + /* host: config.postgres.host, port: config.postgres.port, user: config.postgres.username, password: config.postgres.password, - database: config.postgres.database, + database: config.postgres.database, */ }, // Print all statements verbose: true, diff --git a/package.json b/package.json index 306ffc4d..46495fad 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@logtape/otel": "^1.0.0", "@scalar/hono-api-reference": "^0.9.7", "@sentry/bun": "^9.35.0", + "openid-client": "^6.6.3", "altcha-lib": "^1.3.0", "blurhash": "^2.0.5", "bullmq": "^5.56.1", @@ -73,7 +74,6 @@ "ioredis": "^5.6.1", "ip-matching": "^2.1.2", "iso-639-1": "^3.1.5", - "jose": "^6.0.11", "linkify-html": "^4.3.1", "linkify-string": "^4.3.1", "linkifyjs": "^4.3.1", @@ -85,7 +85,6 @@ "markdown-it-toc-done-right": "^4.2.0", "mime-types": "^3.0.1", "mitata": "^1.0.34", - "oauth4webapi": "^3.5.5", "ora": "^8.2.0", "qs": "^6.14.0", "sharp": "^0.34.2", @@ -191,7 +190,6 @@ "ioredis": "catalog:", "ip-matching": "catalog:", "iso-639-1": "catalog:", - "jose": "catalog:", "linkify-html": "catalog:", "linkify-string": "catalog:", "linkifyjs": "catalog:", @@ -203,7 +201,6 @@ "markdown-it-toc-done-right": "catalog:", "mime-types": "catalog:", "mitata": "catalog:", - "oauth4webapi": "catalog:", "ora": "catalog:", "qs": "catalog:", "sharp": "catalog:", diff --git a/packages/api/package.json b/packages/api/package.json index 2e50ca7b..4053ea1a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,6 +55,7 @@ "@versia-server/logging": "workspace:*", "@versia/client": "workspace:*", "@versia/sdk": "workspace:*", + "openid-client": "catalog:", "youch": "catalog:", "hono": "catalog:", "hono-openapi": "catalog:", @@ -66,7 +67,6 @@ "unicode-emoji-json": "catalog:", "sharp": "catalog:", "iso-639-1": "catalog:", - "jose": "catalog:", "zod-openapi": "catalog:", "@scalar/hono-api-reference": "catalog:", "hono-rate-limiter": "catalog:", @@ -75,7 +75,6 @@ "altcha-lib": "catalog:", "@hono/standard-validator": "catalog:", "zod-validation-error": "catalog:", - "confbox": "catalog:", - "oauth4webapi": "catalog:" + "confbox": "catalog:" } } diff --git a/packages/api/plugins/openid/errors.ts b/packages/api/plugins/openid/errors.ts deleted file mode 100644 index 72202032..00000000 --- a/packages/api/plugins/openid/errors.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Context, TypedResponse } from "hono"; - -export const errors = { - InvalidJWT: ["invalid_request", "Invalid JWT: could not verify"], - MissingJWTFields: [ - "invalid_request", - "Invalid JWT: missing required fields (aud, sub, exp, iss)", - ], - InvalidSub: ["invalid_request", "Invalid JWT: sub is not a valid user ID"], - UserNotFound: [ - "invalid_request", - "Invalid JWT, could not find associated user", - ], - MissingOauthPermission: [ - "unauthorized", - "User missing required 'oauth' permission", - ], - MissingApplication: [ - "invalid_request", - "Invalid client_id: no associated API application found", - ], - InvalidRedirectUri: [ - "invalid_request", - "Invalid redirect_uri: does not match API application's redirect_uri", - ], - InvalidScope: [ - "invalid_request", - "Invalid scope: not a subset of the application's scopes", - ], -}; - -export const errorRedirect = ( - context: Context, - error: (typeof errors)[keyof typeof errors], - extraParams?: URLSearchParams, -): Response & TypedResponse => { - const errorSearchParams = new URLSearchParams(extraParams); - - errorSearchParams.append("error", error[0]); - errorSearchParams.append("error_description", error[1]); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); -}; diff --git a/packages/api/plugins/openid/utils.ts b/packages/api/plugins/openid/utils.ts deleted file mode 100644 index 4f7afdc9..00000000 --- a/packages/api/plugins/openid/utils.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { type Application, db } from "@versia-server/kit/db"; -import type { OpenIdLoginFlows } from "@versia-server/kit/tables"; -import { eq, type InferSelectModel, type SQL } from "drizzle-orm"; -import { - type AuthorizationResponseError, - type AuthorizationServer, - authorizationCodeGrantRequest, - ClientSecretPost, - discoveryRequest, - expectNoState, - getValidatedIdTokenClaims, - processAuthorizationCodeResponse, - processDiscoveryResponse, - processUserInfoResponse, - type ResponseBodyError, - type TokenEndpointResponse, - type UserInfoResponse, - userInfoRequest, - validateAuthResponse, -} from "oauth4webapi"; - -export const oauthDiscoveryRequest = ( - issuerUrl: URL, -): Promise => { - return discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); -}; - -export const oauthRedirectUri = (baseUrl: URL, issuer: string): URL => - new URL(`/oauth/sso/${issuer}/callback`, baseUrl); - -const getFlow = ( - flowId: string, -): Promise< - | (InferSelectModel & { - application?: typeof Application.$type | null; - }) - | undefined -> => { - return db.query.OpenIdLoginFlows.findFirst({ - where: (flow): SQL | undefined => eq(flow.id, flowId), - with: { - application: true, - }, - }); -}; - -const getAuthServer = (issuerUrl: URL): Promise => { - return discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); -}; - -const getParameters = ( - authServer: AuthorizationServer, - clientId: string, - currentUrl: URL, -): URLSearchParams => { - return validateAuthResponse( - authServer, - { - client_id: clientId, - }, - currentUrl, - expectNoState, - ); -}; - -const getOIDCResponse = ( - authServer: AuthorizationServer, - clientId: string, - clientSecret: string, - redirectUri: URL, - codeVerifier: string, - parameters: URLSearchParams, -): Promise => { - return authorizationCodeGrantRequest( - authServer, - { - client_id: clientId, - }, - ClientSecretPost(clientSecret), - parameters, - redirectUri.toString(), - codeVerifier, - ); -}; - -const processOIDCResponse = ( - authServer: AuthorizationServer, - clientId: string, - oidcResponse: Response, -): Promise => { - return processAuthorizationCodeResponse( - authServer, - { - client_id: clientId, - }, - oidcResponse, - ); -}; - -const getUserInfo = ( - authServer: AuthorizationServer, - clientId: string, - accessToken: string, - sub: string, -): Promise => { - return userInfoRequest( - authServer, - { - client_id: clientId, - }, - accessToken, - ).then( - async (res) => - await processUserInfoResponse( - authServer, - { - client_id: clientId, - }, - sub, - res, - ), - ); -}; - -export const automaticOidcFlow = async ( - issuer: { - url: string; - client_id: string; - client_secret: string; - }, - flowId: string, - currentUrl: URL, - redirectUrl: URL, - errorFn: ( - error: string, - message: string, - flow: - | (InferSelectModel & { - application?: typeof Application.$type | null; - }) - | null, - ) => Response, -): Promise< - | Response - | { - userInfo: UserInfoResponse; - flow: InferSelectModel & { - application?: typeof Application.$type | null; - }; - claims: Record; - } -> => { - const flow = await getFlow(flowId); - - if (!flow) { - return errorFn("invalid_request", "Invalid flow", null); - } - - try { - const issuerUrl = new URL(issuer.url); - - const authServer = await getAuthServer(issuerUrl); - - const parameters = getParameters( - authServer, - issuer.client_id, - currentUrl, - ); - - const oidcResponse = await getOIDCResponse( - authServer, - issuer.client_id, - issuer.client_secret, - redirectUrl, - flow.codeVerifier, - parameters, - ); - - const result = await processOIDCResponse( - authServer, - issuer.client_id, - oidcResponse, - ); - - const { access_token } = result; - - const claims = getValidatedIdTokenClaims(result); - - if (!claims) { - return errorFn("invalid_request", "Invalid claims", flow); - } - - const { sub } = claims; - - // Validate `sub` - // Later, we'll use this to automatically set the user's data - const userInfo = await getUserInfo( - authServer, - issuer.client_id, - access_token, - sub, - ); - - return { - userInfo, - flow, - claims, - }; - } catch (e) { - const error = e as ResponseBodyError | AuthorizationResponseError; - return errorFn(error.error, error.error_description || "", flow); - } -}; diff --git a/packages/api/routes/api/auth/login/index.test.ts b/packages/api/routes/api/auth/login/index.test.ts index 1fe79f5d..5dc4c705 100644 --- a/packages/api/routes/api/auth/login/index.test.ts +++ b/packages/api/routes/api/auth/login/index.test.ts @@ -2,19 +2,17 @@ import { afterAll, describe, expect, test } from "bun:test"; import { config } from "@versia-server/config"; import { Application } from "@versia-server/kit/db"; import { fakeRequest, getTestUsers } from "@versia-server/tests"; -import { randomUUIDv7 } from "bun"; import { randomString } from "@/math"; const { users, deleteUsers, passwords } = await getTestUsers(1); // Create application const application = await Application.insert({ - id: randomUUIDv7(), + id: randomString(32, "hex"), name: "Test Application", - clientId: randomString(32, "hex"), secret: "test", - redirectUri: "https://example.com", - scopes: "read write", + redirectUris: ["https://example.com"], + scopes: ["read", "write"], }); afterAll(async () => { @@ -31,7 +29,7 @@ describe("/api/auth/login", () => { formData.append("password", passwords[0]); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", body: formData, @@ -47,7 +45,7 @@ describe("/api/auth/login", () => { expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.clientId, + application.data.id, ); expect(locationHeader.searchParams.get("redirect_uri")).toBe( "https://example.com", @@ -65,7 +63,7 @@ describe("/api/auth/login", () => { formData.append("password", passwords[0]); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", body: formData, @@ -81,7 +79,7 @@ describe("/api/auth/login", () => { expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.clientId, + application.data.id, ); expect(locationHeader.searchParams.get("redirect_uri")).toBe( "https://example.com", @@ -99,7 +97,7 @@ describe("/api/auth/login", () => { formData.append("password", passwords[0]); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`, { method: "POST", body: formData, @@ -115,7 +113,7 @@ describe("/api/auth/login", () => { expect(locationHeader.pathname).toBe("/oauth/consent"); expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.clientId, + application.data.id, ); expect(locationHeader.searchParams.get("redirect_uri")).toBe( "https://example.com", @@ -136,7 +134,7 @@ describe("/api/auth/login", () => { formData.append("password", "password"); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", @@ -169,7 +167,7 @@ describe("/api/auth/login", () => { formData.append("password", "password"); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", body: formData, @@ -201,7 +199,7 @@ describe("/api/auth/login", () => { formData.append("password", "password"); const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, + `/api/auth/login?client_id=${application.data.id}&redirect_uri=https://example.com&response_type=code&scope=read+write`, { method: "POST", body: formData, diff --git a/packages/api/routes/api/auth/login/index.ts b/packages/api/routes/api/auth/login/index.ts index 1a3c9d19..8db55a49 100644 --- a/packages/api/routes/api/auth/login/index.ts +++ b/packages/api/routes/api/auth/login/index.ts @@ -7,8 +7,8 @@ import { password as bunPassword } from "bun"; import { eq, or } from "drizzle-orm"; import type { Context } from "hono"; import { setCookie } from "hono/cookie"; +import { sign } from "hono/jwt"; import { describeRoute, validator } from "hono-openapi"; -import { SignJWT } from "jose"; import { z } from "zod/v4"; const returnError = ( @@ -144,16 +144,17 @@ export default apiRoute((app) => } // Generate JWT - const jwt = await new SignJWT({ - sub: user.id, - iss: 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), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); + const jwt = await sign( + { + sub: user.id, + iss: 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), + }, + config.authentication.keys.private, + ); const application = await Application.fromClientId(client_id); diff --git a/packages/api/routes/api/auth/redirect/index.ts b/packages/api/routes/api/auth/redirect/index.ts deleted file mode 100644 index 5e43431d..00000000 --- a/packages/api/routes/api/auth/redirect/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { config } from "@versia-server/config"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db } from "@versia-server/kit/db"; -import { Applications, Tokens } from "@versia-server/kit/tables"; -import { and, eq } from "drizzle-orm"; -import { describeRoute, validator } from "hono-openapi"; -import { z } from "zod/v4"; - -/** - * OAuth Code flow - */ -export default apiRoute((app) => - app.get( - "/api/auth/redirect", - describeRoute({ - summary: "OAuth Code flow", - description: - "Redirects to the application, or back to login if the code is invalid", - tags: ["OpenID"], - responses: { - 302: { - description: - "Redirects to the application, or back to login if the code is invalid", - }, - }, - }), - validator( - "query", - z.object({ - redirect_uri: z.url(), - client_id: z.string(), - code: z.string(), - }), - handleZodError, - ), - async (context) => { - const { redirect_uri, client_id, code } = - context.req.valid("query"); - - const redirectToLogin = (error: string): Response => - context.redirect( - `${config.frontend.routes.login}?${new URLSearchParams({ - ...context.req.query, - error: encodeURIComponent(error), - }).toString()}`, - ); - - const foundToken = await db - .select() - .from(Tokens) - .leftJoin( - Applications, - eq(Tokens.applicationId, Applications.id), - ) - .where( - and( - eq(Tokens.code, code), - eq(Applications.clientId, client_id), - ), - ) - .limit(1); - - if (!foundToken || foundToken.length <= 0) { - return redirectToLogin("Invalid code"); - } - - // Redirect back to application - return context.redirect( - `${redirect_uri}?${new URLSearchParams({ - code, - }).toString()}`, - ); - }, - ), -); diff --git a/packages/api/routes/api/auth/reset/index.test.ts b/packages/api/routes/api/auth/reset/index.test.ts deleted file mode 100644 index 2bb3ed5d..00000000 --- a/packages/api/routes/api/auth/reset/index.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { config } from "@versia-server/config"; -import { Application } from "@versia-server/kit/db"; -import { fakeRequest, getTestUsers } from "@versia-server/tests"; -import { randomUUIDv7 } from "bun"; -import { randomString } from "@/math"; - -const { users, deleteUsers, passwords } = await getTestUsers(1); -const token = randomString(32, "hex"); -const newPassword = randomString(16, "hex"); - -// Create application -const application = await Application.insert({ - id: randomUUIDv7(), - name: "Test Application", - clientId: randomString(32, "hex"), - secret: "test", - redirectUri: "https://example.com", - scopes: "read write", -}); - -afterAll(async () => { - await deleteUsers(); - await application.delete(); -}); - -// /api/auth/reset -describe("/api/auth/reset", () => { - test("should login with normal password", async () => { - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.username ?? ""); - formData.append("password", passwords[0]); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - }); - - test("should reset password and refuse login with old password", async () => { - await users[0]?.update({ - passwordResetToken: token, - }); - - const formData = new FormData(); - - formData.append("identifier", users[0]?.data.username ?? ""); - formData.append("password", passwords[0]); - - const response = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: formData, - }, - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - - expect(locationHeader.pathname).toBe("/oauth/reset"); - expect(locationHeader.searchParams.get("token")).toBe(token); - }); - - test("should reset password and login with new password", async () => { - const formData = new FormData(); - - formData.append("token", token); - formData.append("password", newPassword); - formData.append("password2", newPassword); - - const response = await fakeRequest("/api/auth/reset", { - method: "POST", - body: formData, - }); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toBeDefined(); - - const loginFormData = new FormData(); - - loginFormData.append("identifier", users[0]?.data.username ?? ""); - loginFormData.append("password", newPassword); - - const loginResponse = await fakeRequest( - `/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`, - { - method: "POST", - body: loginFormData, - }, - ); - - expect(loginResponse.status).toBe(302); - expect(loginResponse.headers.get("location")).toBeDefined(); - const locationHeader = new URL( - loginResponse.headers.get("Location") ?? "", - config.http.base_url, - ); - - expect(locationHeader.pathname).toBe("/oauth/consent"); - expect(locationHeader.searchParams.get("client_id")).toBe( - application.data.clientId, - ); - expect(locationHeader.searchParams.get("redirect_uri")).toBe( - "https://example.com", - ); - expect(locationHeader.searchParams.get("response_type")).toBe("code"); - expect(locationHeader.searchParams.get("scope")).toBe("read write"); - - expect(loginResponse.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/); - }); -}); diff --git a/packages/api/routes/api/auth/reset/index.ts b/packages/api/routes/api/auth/reset/index.ts deleted file mode 100644 index f45a12ab..00000000 --- a/packages/api/routes/api/auth/reset/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { config } from "@versia-server/config"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { User } from "@versia-server/kit/db"; -import { Users } from "@versia-server/kit/tables"; -import { password as bunPassword } from "bun"; -import { eq } from "drizzle-orm"; -import type { Context } from "hono"; -import { describeRoute, validator } from "hono-openapi"; -import { z } from "zod/v4"; - -const returnError = ( - context: Context, - token: string, - error: string, - description: string, -): Response => { - const searchParams = new URLSearchParams(); - - searchParams.append("error", error); - searchParams.append("error_description", description); - searchParams.append("token", token); - - return context.redirect( - new URL( - `${ - config.frontend.routes.password_reset - }?${searchParams.toString()}`, - config.http.base_url, - ).toString(), - ); -}; - -export default apiRoute((app) => - app.post( - "/api/auth/reset", - describeRoute({ - summary: "Reset password", - description: "Reset password", - responses: { - 302: { - description: - "Redirect to the password reset page with a message", - }, - }, - }), - validator( - "form", - z.object({ - token: z.string().min(1), - password: z.string().min(3).max(100), - }), - handleZodError, - ), - async (context) => { - const { token, password } = context.req.valid("form"); - - const user = await User.fromSql( - eq(Users.passwordResetToken, token), - ); - - if (!user) { - return returnError( - context, - token, - "invalid_token", - "Invalid token", - ); - } - - await user.update({ - password: await bunPassword.hash(password), - passwordResetToken: null, - }); - - return context.redirect( - `${config.frontend.routes.password_reset}?success=true`, - ); - }, - ), -); diff --git a/packages/api/routes/api/oauth/authorize.test.ts b/packages/api/routes/api/oauth/authorize.test.ts deleted file mode 100644 index 3c002d12..00000000 --- a/packages/api/routes/api/oauth/authorize.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { RolePermission } from "@versia/client/schemas"; -import { config } from "@versia-server/config"; -import { Application } from "@versia-server/kit/db"; -import { fakeRequest, getTestUsers } from "@versia-server/tests"; -import { randomUUIDv7 } from "bun"; -import { SignJWT } from "jose"; -import { randomString } from "@/math"; - -const { deleteUsers, tokens, users } = await getTestUsers(1); - -const application = await Application.insert({ - id: randomUUIDv7(), - clientId: "test-client-id", - redirectUri: "https://example.com/callback", - scopes: "openid profile email", - name: "Test Application", - secret: "test-secret", -}); - -afterAll(async () => { - await deleteUsers(); - await application.delete(); -}); - -describe("/oauth/authorize", () => { - test("should authorize and redirect with valid inputs", async () => { - const jwt = await new SignJWT({ - sub: users[0].id, - iss: config.http.base_url.origin, - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(location.origin + location.pathname).toBe( - application.data.redirectUri, - ); - expect(params.get("code")).toBeTruthy(); - expect(params.get("state")).toBe("test-state"); - }); - - test("should return error for invalid JWT", async () => { - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: "jwt=invalid-jwt", - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid JWT: could not verify", - ); - }); - - test("should return error for missing required fields in JWT", async () => { - const jwt = await new SignJWT({ - sub: users[0].id, - iss: config.http.base_url.origin, - aud: application.data.clientId, - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid JWT: missing required fields (aud, sub, exp, iss)", - ); - }); - - test("should return error for user not found", async () => { - const jwt = await new SignJWT({ - sub: "non-existent-user", - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iss: config.http.base_url.origin, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid JWT: sub is not a valid user ID", - ); - - const jwt2 = await new SignJWT({ - sub: "23e42862-d5df-49a8-95b5-52d8c6a11aea", - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iss: config.http.base_url.origin, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response2 = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt2}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response2.status).toBe(302); - const location2 = new URL( - response2.headers.get("Location") ?? "", - config.http.base_url, - ); - const params2 = new URLSearchParams(location2.search); - expect(params2.get("error")).toBe("invalid_request"); - expect(params2.get("error_description")).toBe( - "Invalid JWT, could not find associated user", - ); - }); - - test("should return error for user missing required permissions", async () => { - const oldPermissions = config.permissions.default; - config.permissions.default = []; - - const jwt = await new SignJWT({ - sub: users[0].id, - iss: config.http.base_url.origin, - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("unauthorized"); - expect(params.get("error_description")).toBe( - `User missing required '${RolePermission.OAuth}' permission`, - ); - - config.permissions.default = oldPermissions; - }); - - test("should return error for invalid client_id", async () => { - const jwt = await new SignJWT({ - sub: users[0].id, - aud: "invalid-client-id", - iss: config.http.base_url.origin, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: "invalid-client-id", - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid client_id: no associated API application found", - ); - }); - - test("should return error for invalid redirect_uri", async () => { - const jwt = await new SignJWT({ - sub: users[0].id, - iss: config.http.base_url.origin, - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: "https://invalid.com/callback", - response_type: "code", - scope: application.data.scopes, - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid redirect_uri: does not match API application's redirect_uri", - ); - }); - - test("should return error for invalid scope", async () => { - const jwt = await new SignJWT({ - sub: users[0].id, - iss: config.http.base_url.origin, - aud: application.data.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); - - const response = await fakeRequest("/oauth/authorize", { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].data.accessToken}`, - "Content-Type": "application/json", - Cookie: `jwt=${jwt}`, - }, - body: JSON.stringify({ - client_id: application.data.clientId, - redirect_uri: application.data.redirectUri, - response_type: "code", - scope: "invalid-scope", - state: "test-state", - code_challenge: randomString(43), - code_challenge_method: "S256", - }), - }); - - expect(response.status).toBe(302); - const location = new URL( - response.headers.get("Location") ?? "", - config.http.base_url, - ); - const params = new URLSearchParams(location.search); - expect(params.get("error")).toBe("invalid_request"); - expect(params.get("error_description")).toBe( - "Invalid scope: not a subset of the application's scopes", - ); - }); -}); diff --git a/packages/api/routes/api/oauth/authorize.ts b/packages/api/routes/api/oauth/authorize.ts deleted file mode 100644 index 92e83657..00000000 --- a/packages/api/routes/api/oauth/authorize.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { RolePermission } from "@versia/client/schemas"; -import { config } from "@versia-server/config"; -import { - apiRoute, - auth, - handleZodError, - jsonOrForm, -} from "@versia-server/kit/api"; -import { Application, Token, User } from "@versia-server/kit/db"; -import { randomUUIDv7 } from "bun"; -import { describeRoute, validator } from "hono-openapi"; -import { type JWTPayload, jwtVerify, SignJWT } from "jose"; -import { JOSEError } from "jose/errors"; -import { z } from "zod/v4"; -import { randomString } from "@/math"; -import { errorRedirect, errors } from "../../../plugins/openid/errors.ts"; - -export default apiRoute((app) => - app.post( - "/oauth/authorize", - describeRoute({ - summary: "Main OpenID authorization endpoint", - tags: ["OpenID"], - responses: { - 302: { - description: "Redirect to the application", - }, - }, - }), - auth({ - auth: false, - }), - jsonOrForm(), - validator( - "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), - }), - handleZodError, - ), - validator( - "json", - z - .object({ - scope: z.string().optional(), - redirect_uri: z - .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", - ), */ - handleZodError, - ), - validator( - "cookie", - z.object({ - jwt: z.string(), - }), - handleZodError, - ), - async (context) => { - const { scope, redirect_uri, client_id, state } = - context.req.valid("json"); - - const { jwt } = context.req.valid("cookie"); - - const errorSearchParams = new URLSearchParams( - context.req.valid("json"), - ); - - const result = await jwtVerify( - jwt, - config.authentication.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) { - return errorRedirect( - context, - errors.InvalidJWT, - errorSearchParams, - ); - } - - const { - payload: { aud, sub, exp }, - } = result; - - if (!(aud && sub && exp)) { - return errorRedirect( - context, - errors.MissingJWTFields, - errorSearchParams, - ); - } - - if (!z.uuid().safeParse(sub).success) { - return errorRedirect( - context, - errors.InvalidSub, - errorSearchParams, - ); - } - - const user = await User.fromId(sub); - - if (!user) { - return errorRedirect( - context, - errors.UserNotFound, - errorSearchParams, - ); - } - - if (!user.hasPermission(RolePermission.OAuth)) { - return errorRedirect( - context, - errors.MissingOauthPermission, - errorSearchParams, - ); - } - - const application = await Application.fromClientId(client_id); - - if (!application) { - return errorRedirect( - context, - errors.MissingApplication, - errorSearchParams, - ); - } - - if (application.data.redirectUri !== redirect_uri) { - return errorRedirect( - context, - errors.InvalidRedirectUri, - errorSearchParams, - ); - } - - // Check that scopes are a subset of the application's scopes - if ( - scope && - !scope - .split(" ") - .every((s) => application.data.scopes.includes(s)) - ) { - return errorRedirect( - context, - errors.InvalidScope, - errorSearchParams, - ); - } - - 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().href, - 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(config.authentication.keys.private); - - await Token.insert({ - id: randomUUIDv7(), - accessToken: randomString(64, "base64url"), - code, - scope: scope ?? application.data.scopes, - tokenType: "Bearer", - applicationId: application.id, - redirectUri: redirect_uri ?? application.data.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.data.redirectUri); - - redirectUri.searchParams.append("code", code); - state && redirectUri.searchParams.append("state", state); - - return context.redirect(redirectUri.toString()); - }, - ), -); diff --git a/packages/api/routes/api/oauth/revoke.test.ts b/packages/api/routes/api/oauth/revoke.test.ts index 4d469b94..e7d19afb 100644 --- a/packages/api/routes/api/oauth/revoke.test.ts +++ b/packages/api/routes/api/oauth/revoke.test.ts @@ -7,25 +7,20 @@ const { deleteUsers, users } = await getTestUsers(1); const application = await Application.insert({ id: randomUUIDv7(), - clientId: "test-client-id", - redirectUri: "https://example.com/callback", - scopes: "openid profile email", + redirectUris: ["https://example.com/callback"], + scopes: ["openid", "profile", "email"], secret: "test-secret", name: "Test Application", }); const token = await Token.insert({ id: randomUUIDv7(), - code: "test-code", - redirectUri: application.data.redirectUri, - clientId: application.data.clientId, + clientId: application.id, accessToken: "test-access-token", expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), createdAt: new Date().toISOString(), - tokenType: "Bearer", - scope: application.data.scopes, + scopes: application.data.scopes, userId: users[0].id, - applicationId: application.id, }); afterAll(async () => { @@ -42,7 +37,7 @@ describe("/oauth/revoke", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - client_id: application.data.clientId, + client_id: application.data.id, client_secret: application.data.secret, token: "test-access-token", }), @@ -60,7 +55,7 @@ describe("/oauth/revoke", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - client_id: application.data.clientId, + client_id: application.data.id, client_secret: application.data.secret, }), }); @@ -80,7 +75,7 @@ describe("/oauth/revoke", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - client_id: application.data.clientId, + client_id: application.data.id, client_secret: "invalid-secret", token: "test-access-token", }), @@ -101,7 +96,7 @@ describe("/oauth/revoke", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - client_id: application.data.clientId, + client_id: application.data.id, client_secret: application.data.secret, token: "invalid-token", }), diff --git a/packages/api/routes/api/oauth/revoke.ts b/packages/api/routes/api/oauth/revoke.ts index 36b0a4f2..a664d1ec 100644 --- a/packages/api/routes/api/oauth/revoke.ts +++ b/packages/api/routes/api/oauth/revoke.ts @@ -68,7 +68,7 @@ export default apiRoute((app) => { } // Check if the client secret is correct - if (foundToken.data.application?.secret !== client_secret) { + if (foundToken.data.client?.secret !== client_secret) { return context.json( { error: "unauthorized_client", diff --git a/packages/api/routes/api/oauth/sso.ts b/packages/api/routes/api/oauth/sso.ts deleted file mode 100644 index b58bcfe0..00000000 --- a/packages/api/routes/api/oauth/sso.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { config } from "@versia-server/config"; -import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { Application, db } from "@versia-server/kit/db"; -import { OpenIdLoginFlows } from "@versia-server/kit/tables"; -import { randomUUIDv7 } from "bun"; -import { describeRoute, validator } from "hono-openapi"; -import { - calculatePKCECodeChallenge, - discoveryRequest, - generateRandomCodeVerifier, - processDiscoveryResponse, -} from "oauth4webapi"; -import { z } from "zod/v4"; -import { oauthRedirectUri } from "../../../plugins/openid/utils.ts"; - -export default apiRoute((app) => { - app.get( - "/oauth/sso", - describeRoute({ - summary: "Initiate SSO login flow", - tags: ["OpenID"], - responses: { - 302: { - description: - "Redirect to SSO login, or redirect to login page with error", - }, - }, - }), - validator( - "query", - z.object({ - issuer: z.string(), - client_id: z.string().optional(), - redirect_uri: z.url().optional(), - scope: z.string().optional(), - response_type: z.enum(["code"]).optional(), - }), - handleZodError, - ), - async (context) => { - // This is the Versia client's client_id, not the external OAuth provider's client_id - const { issuer: issuerId, client_id } = context.req.valid("query"); - - const errorSearchParams = new URLSearchParams( - context.req.valid("query"), - ); - - if (!client_id || client_id === "undefined") { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "client_id is required", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - const issuer = config.authentication.openid_providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "issuer is invalid", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - const issuerUrl = new URL(issuer.url); - - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - - const codeVerifier = generateRandomCodeVerifier(); - - const application = await Application.fromClientId(client_id); - - if (!application) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "client_id is invalid", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - id: randomUUIDv7(), - codeVerifier, - applicationId: application.id, - issuerId, - }) - .returning() - )[0]; - - const codeChallenge = - await calculatePKCECodeChallenge(codeVerifier); - - return context.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: `${oauthRedirectUri( - context.get("config").http.base_url, - issuerId, - )}?flow=${newFlow.id}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString()}`, - ); - }, - ); -}); diff --git a/packages/api/routes/api/oauth/sso/[issuer]/callback.ts b/packages/api/routes/api/oauth/sso/[issuer]/callback.ts index b9c1a706..ef7b7b2d 100644 --- a/packages/api/routes/api/oauth/sso/[issuer]/callback.ts +++ b/packages/api/routes/api/oauth/sso/[issuer]/callback.ts @@ -6,17 +6,21 @@ import { import { config } from "@versia-server/config"; import { ApiError } from "@versia-server/kit"; import { apiRoute, handleZodError } from "@versia-server/kit/api"; -import { db, Media, Token, User } from "@versia-server/kit/db"; +import { db, Media, User } from "@versia-server/kit/db"; import { searchManager } from "@versia-server/kit/search"; -import { OpenIdAccounts, Users } from "@versia-server/kit/tables"; +import { + AuthorizationCodes, + OpenIdAccounts, + Users, +} from "@versia-server/kit/tables"; import { randomUUIDv7 } from "bun"; import { and, eq, isNull, type SQL } from "drizzle-orm"; import { setCookie } from "hono/cookie"; +import { sign } from "hono/jwt"; import { describeRoute, validator } from "hono-openapi"; -import { SignJWT } from "jose"; +import * as client from "openid-client"; import { z } from "zod/v4"; import { randomString } from "@/math.ts"; -import { automaticOidcFlow } from "../../../../../plugins/openid/utils.ts"; export default apiRoute((app) => { app.get( @@ -31,6 +35,7 @@ export default apiRoute((app) => { description: "Redirect to frontend's consent route, or redirect to login page with error", }, + 422: ApiError.validationFailed().schema, }, }), validator( @@ -43,103 +48,94 @@ export default apiRoute((app) => { validator( "query", z.object({ - client_id: z.string().optional(), flow: z.string(), - link: zBoolean.optional(), + link: zBoolean.default(false), user_id: z.uuid().optional(), }), handleZodError, ), async (context) => { - const currentUrl = new URL(context.req.url); - const redirectUrl = new URL(context.req.url); - - // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https - // Looking at you, Traefik - if ( - new URL(context.get("config").http.base_url).protocol === - "https:" && - currentUrl.protocol === "http:" - ) { - currentUrl.protocol = "https:"; - redirectUrl.protocol = "https:"; - } - - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - redirectUrl.searchParams.delete("state"); - // Remove issuer query parameter from URL (can cause redirect URI mismatches) - redirectUrl.searchParams.delete("iss"); - redirectUrl.searchParams.delete("code"); - const { issuer: issuerParam } = context.req.valid("param"); + const { issuer: issuerId } = context.req.valid("param"); const { flow: flowId, user_id, link } = context.req.valid("query"); const issuer = config.authentication.openid_providers.find( - (provider) => provider.id === issuerParam, + (provider) => provider.id === issuerId, ); if (!issuer) { - throw new ApiError(404, "Issuer not found"); + throw new ApiError(422, "Unknown or invalid issuer"); } - const userInfo = await automaticOidcFlow( - issuer, - flowId, - currentUrl, - redirectUrl, - (error, message, flow) => { - const errorSearchParams = new URLSearchParams( - Object.entries({ - redirect_uri: flow?.application?.redirectUri, - client_id: flow?.application?.clientId, - response_type: "code", - scope: flow?.application?.scopes, - }).filter(([_, value]) => value !== undefined) as [ - string, - string, - ][], - ); + const flow = await db.query.OpenIdLoginFlows.findFirst({ + where: (flow): SQL | undefined => eq(flow.id, flowId), + with: { + application: true, + }, + }); - errorSearchParams.append("error", error); - errorSearchParams.append("error_description", message); + const redirectWithMessage = ( + parameters: Record, + route = config.frontend.routes.login, + ) => { + const searchParams = new URLSearchParams( + Object.entries(parameters).filter( + ([_, value]) => value !== undefined, + ) as [string, string][], + ); - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); + return context.redirect(`${route}?${searchParams.toString()}`); + }; + + if (!flow) { + return redirectWithMessage({ + error: "invalid_request", + error_description: "Invalid flow", + }); + } + + const oidcConfig = await client.discovery( + issuer.url, + issuer.client_id, + issuer.client_secret, + ); + + const tokens = await client.authorizationCodeGrant( + oidcConfig, + context.req.raw, + { + pkceCodeVerifier: flow.codeVerifier, + expectedState: flow.state ?? undefined, + idTokenExpected: true, }, ); - if (userInfo instanceof Response) { - return userInfo; + const claims = tokens.claims(); + + if (!claims) { + return redirectWithMessage({ + error: "invalid_request", + error_description: "Missing or invalid ID token", + }); } - const { sub, email, preferred_username, picture } = - userInfo.userInfo; - const flow = userInfo.flow; - - const errorSearchParams = new URLSearchParams( - Object.entries({ - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }).filter(([_, value]) => value !== undefined) as [ - string, - string, - ][], + const userInfo = await client.fetchUserInfo( + oidcConfig, + tokens.access_token, + claims.sub, ); + const { sub, email, preferred_username, picture } = userInfo; + // If linking account if (link && user_id) { // Check if userId is equal to application.clientId - if (!flow.application?.clientId.startsWith(user_id)) { - return context.redirect( - `${context.get("config").http.base_url}${ - context.get("config").frontend.routes.home - }?${new URLSearchParams({ + if (!flow.application?.id.startsWith(user_id)) { + return redirectWithMessage( + { oidc_account_linking_error: "Account linking error", - oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`, - })}`, + oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.id})`, + }, + config.frontend.routes.home, ); } @@ -153,15 +149,14 @@ export default apiRoute((app) => { }); if (account) { - return context.redirect( - `${context.get("config").http.base_url}${ - context.get("config").frontend.routes.home - }?${new URLSearchParams({ + return redirectWithMessage( + { oidc_account_linking_error: "Account already linked", oidc_account_linking_error_message: "This account has already been linked to this OpenID Connect provider.", - })}`, + }, + config.frontend.routes.home, ); } @@ -244,42 +239,27 @@ export default apiRoute((app) => { userId = user.id; } else { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "No user found with that account", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); + return redirectWithMessage({ + error: "invalid_request", + error_description: "No user found with that account", + }); } } const user = await User.fromId(userId); if (!user) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "No user found with that account", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); + return redirectWithMessage({ + error: "invalid_request", + error_description: "No user found with that account", + }); } if (!user.hasPermission(RolePermission.OAuth)) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - `User does not have the '${RolePermission.OAuth}' permission`, - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); + return redirectWithMessage({ + error: "invalid_request", + error_description: `User does not have the '${RolePermission.OAuth}' permission`, + }); } if (!flow.application) { @@ -288,27 +268,26 @@ export default apiRoute((app) => { const code = randomString(32, "hex"); - await Token.insert({ - id: randomUUIDv7(), - accessToken: randomString(64, "base64url"), + await db.insert(AuthorizationCodes).values({ + clientId: flow.application.id, code, - scope: flow.application.scopes, - tokenType: "Bearer", + expiresAt: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute + redirectUri: flow.clientRedirectUri ?? undefined, userId: user.id, - applicationId: flow.application.id, + scopes: flow.clientScopes ?? [], }); - // Generate JWT - const jwt = await new SignJWT({ - sub: user.id, - iss: new URL(context.get("config").http.base_url).origin, - aud: flow.application.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(config.authentication.keys.private); + const jwt = await sign( + { + sub: user.id, + iss: new URL(context.get("config").http.base_url).origin, + aud: flow.application.id, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + iat: Math.floor(Date.now() / 1000), + nbf: Math.floor(Date.now() / 1000), + }, + config.authentication.keys.private, + ); // Redirect back to application setCookie(context, "jwt", jwt, { @@ -320,21 +299,17 @@ export default apiRoute((app) => { maxAge: 60 * 60 * 24 * 14, }); - return context.redirect( - new URL( - `${context.get("config").frontend.routes.consent}?${new URLSearchParams( - { - redirect_uri: flow.application.redirectUri, - code, - client_id: flow.application.clientId, - application: flow.application.name, - website: flow.application.website ?? "", - scope: flow.application.scopes, - response_type: "code", - }, - ).toString()}`, - context.get("config").http.base_url, - ).toString(), + return redirectWithMessage( + { + redirect_uri: flow.clientRedirectUri ?? undefined, + code, + client_id: flow.application.id, + application: flow.application.name, + website: flow.application.website ?? "", + scope: flow.clientScopes?.join(" "), + state: flow.clientState ?? undefined, + }, + config.frontend.routes.consent, ); }, ); diff --git a/packages/api/routes/api/oauth/sso/[issuer]/index.ts b/packages/api/routes/api/oauth/sso/[issuer]/index.ts new file mode 100644 index 00000000..4b4bf9df --- /dev/null +++ b/packages/api/routes/api/oauth/sso/[issuer]/index.ts @@ -0,0 +1,122 @@ +import { config } from "@versia-server/config"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, handleZodError } from "@versia-server/kit/api"; +import { Application, db } from "@versia-server/kit/db"; +import { OpenIdLoginFlows } from "@versia-server/kit/tables"; +import { randomUUIDv7 } from "bun"; +import { describeRoute, validator } from "hono-openapi"; +import * as client from "openid-client"; +import { z } from "zod/v4"; +import { oauthRedirectUri } from "@/lib"; + +export default apiRoute((app) => { + app.post( + "/oauth/sso/:issuer", + describeRoute({ + summary: "Initiate SSO login flow", + tags: ["OpenID"], + responses: { + 302: { + description: + "Redirect to SSO provider's authorization endpoint", + }, + 422: ApiError.validationFailed().schema, + }, + }), + validator( + "param", + z.object({ + issuer: z.string(), + }), + handleZodError, + ), + validator( + "json", + z.object({ + client_id: z.string(), + redirect_uri: z.url(), + scopes: z.string().array().default(["read"]), + state: z.string().optional(), + }), + handleZodError, + ), + async (context) => { + // This is the Versia client's client_id, not the external OAuth provider's client_id + const { client_id, redirect_uri, scopes, state } = + context.req.valid("json"); + const { issuer: issuerId } = context.req.valid("param"); + + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + throw new ApiError(422, "Unknown or invalid issuer"); + } + + const application = await Application.fromClientId(client_id); + + if (!application) { + throw new ApiError(422, "Unknown or invalid client_id"); + } + + if (!application.data.redirectUris.includes(redirect_uri)) { + throw new ApiError( + 422, + "redirect_uri is not a subset of application's redirect_uris", + ); + } + // TODO: Validate oauth scopes + + const oidcConfig = await client.discovery( + issuer.url, + issuer.client_id, + issuer.client_secret, + ); + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = + await client.calculatePKCECodeChallenge(codeVerifier); + + const parameters: Record = { + scope: "openid profile email", + code_challenge: codeChallenge, + code_challenge_method: "S256", + }; + + if (!oidcConfig.serverMetadata().supportsPKCE()) { + parameters.state = client.randomState(); + } + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + id: randomUUIDv7(), + codeVerifier, + state: parameters.state, + clientState: state, + clientRedirectUri: redirect_uri, + clientScopes: scopes, + applicationId: application.id, + issuerId, + }) + .returning() + )[0]; + + parameters.redirect_uri = `${oauthRedirectUri( + context.get("config").http.base_url, + issuerId, + )}?${new URLSearchParams({ + flow: newFlow.id, + })}`; + + const redirectTo = client.buildAuthorizationUrl( + oidcConfig, + parameters, + ); + + return context.redirect(redirectTo); + }, + ); +}); diff --git a/packages/api/routes/api/oauth/token.test.ts b/packages/api/routes/api/oauth/token.test.ts index cb53156e..27d5f23c 100644 --- a/packages/api/routes/api/oauth/token.test.ts +++ b/packages/api/routes/api/oauth/token.test.ts @@ -7,23 +7,18 @@ const { deleteUsers, users } = await getTestUsers(1); const application = await Application.insert({ id: randomUUIDv7(), - clientId: "test-client-id", - redirectUri: "https://example.com/callback", - scopes: "openid profile email", + redirectUris: ["https://example.com/callback"], + scopes: ["openid", "profile", "email"], secret: "test-secret", name: "Test Application", }); const token = await Token.insert({ id: randomUUIDv7(), - code: "test-code", - redirectUri: application.data.redirectUri, - clientId: application.data.clientId, + clientId: application.data.id, accessToken: "test-access-token", expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), createdAt: new Date().toISOString(), - tokenType: "Bearer", - scope: application.data.scopes, userId: users[0].id, }); @@ -43,8 +38,8 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "authorization_code", code: "test-code", - redirect_uri: application.data.redirectUri, - client_id: application.data.clientId, + redirect_uri: application.data.redirectUris[0], + client_id: application.data.id, client_secret: application.data.secret, }), }); @@ -64,8 +59,8 @@ describe("/oauth/token", () => { }, body: JSON.stringify({ grant_type: "authorization_code", - redirect_uri: application.data.redirectUri, - client_id: application.data.clientId, + redirect_uri: application.data.redirectUris[0], + client_id: application.data.id, client_secret: application.data.secret, }), }); @@ -85,7 +80,7 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "authorization_code", code: "test-code", - client_id: application.data.clientId, + client_id: application.data.id, client_secret: application.data.secret, }), }); @@ -105,7 +100,7 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "authorization_code", code: "test-code", - redirect_uri: application.data.redirectUri, + redirect_uri: application.data.redirectUris[0], client_secret: application.data.secret, }), }); @@ -125,8 +120,8 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "authorization_code", code: "test-code", - redirect_uri: application.data.redirectUri, - client_id: application.data.clientId, + redirect_uri: application.data.redirectUris[0], + client_id: application.data.id, client_secret: "invalid-secret", }), }); @@ -146,8 +141,8 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "authorization_code", code: "invalid-code", - redirect_uri: application.data.redirectUri, - client_id: application.data.clientId, + redirect_uri: application.data.redirectUris[0], + client_id: application.data.id, client_secret: application.data.secret, }), }); @@ -167,8 +162,8 @@ describe("/oauth/token", () => { body: JSON.stringify({ grant_type: "refresh_token", code: "test-code", - redirect_uri: application.data.redirectUri, - client_id: application.data.clientId, + redirect_uri: application.data.redirectUris[0], + client_id: application.data.id, client_secret: application.data.secret, }), }); diff --git a/packages/api/routes/api/oauth/token.ts b/packages/api/routes/api/oauth/token.ts index cd333ee6..ac78b130 100644 --- a/packages/api/routes/api/oauth/token.ts +++ b/packages/api/routes/api/oauth/token.ts @@ -1,43 +1,35 @@ +import { Token as TokenSchema } from "@versia/client/schemas"; import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api"; -import { Application, Token } from "@versia-server/kit/db"; -import { Tokens } from "@versia-server/kit/tables"; +import { Application, db, Token } from "@versia-server/kit/db"; +import { AuthorizationCodes } from "@versia-server/kit/tables"; +import { randomUUIDv7 } from "bun"; import { and, eq } from "drizzle-orm"; import { describeRoute, resolver, validator } from "hono-openapi"; import { z } from "zod/v4"; +import { randomString } from "@/math"; export default apiRoute((app) => { app.post( "/oauth/token", describeRoute({ - summary: "Get token", + 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", + }, tags: ["OpenID"], responses: { 200: { description: "Token", content: { "application/json": { - schema: resolver( - 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(), - }), - ), + schema: resolver(TokenSchema), }, }, }, 401: { - description: "Authorization error", + description: "Invalid grant", content: { "application/json": { schema: resolver( @@ -55,135 +47,87 @@ export default apiRoute((app) => { validator( "json", z.object({ - code: z.string().optional(), + code: z.string(), + grant_type: z.enum([ + "authorization_code", + "refresh_token", + "client_credentials", + ]), 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.url().optional(), + client_id: z.string(), + client_secret: z.string(), + redirect_uri: z.url(), 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(), + scope: z.string().default("read"), }), handleZodError, ), async (context) => { - const { grant_type, code, redirect_uri, client_id, client_secret } = + const { code, client_id, client_secret, redirect_uri } = context.req.valid("json"); - switch (grant_type) { - case "authorization_code": { - if (!code) { - return context.json( - { - error: "invalid_request", - error_description: "Code is required", - }, - 401, - ); - } + // Verify the client_secret + const client = await Application.fromClientId(client_id); - 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 Application.fromClientId(client_id); - - if (!client || client.data.secret !== client_secret) { - return context.json( - { - error: "invalid_client", - error_description: "Invalid client credentials", - }, - 401, - ); - } - - const token = await Token.fromSql( - and( - eq(Tokens.code, code), - eq(Tokens.redirectUri, decodeURI(redirect_uri)), - eq(Tokens.clientId, client_id), - ), - ); - - if (!token) { - return context.json( - { - error: "invalid_grant", - error_description: "Code not found", - }, - 401, - ); - } - - // Invalidate the code - await token.update({ code: null }); - - return context.json( - { - ...token.toApi(), - expires_in: token.data.expiresAt - ? Math.floor( - (new Date( - token.data.expiresAt, - ).getTime() - - Date.now()) / - 1000, - ) - : null, - id_token: token.data.idToken, - refresh_token: null, - }, - 200, - ); - } - - default: + if (!client || client.data.secret !== client_secret) { + return context.json( + { + error: "invalid_client", + error_description: "Invalid client credentials", + }, + 401, + ); } + const authorizationCode = + await db.query.AuthorizationCodes.findFirst({ + where: (codeTable) => + and( + eq(codeTable.code, code), + eq(codeTable.redirectUri, redirect_uri), + eq(codeTable.clientId, client.id), + ), + }); + + if ( + !authorizationCode || + new Date(authorizationCode.expiresAt).getTime() < Date.now() + ) { + return context.json( + { + error: "invalid_grant", + error_description: + "Authorization code not found or expired", + }, + 404, + ); + } + + const token = await Token.insert({ + accessToken: randomString(64, "base64url"), + clientId: client.id, + id: randomUUIDv7(), + userId: authorizationCode.userId, + }); + + // Invalidate the code + await db + .delete(AuthorizationCodes) + .where(eq(AuthorizationCodes.code, authorizationCode.code)); + return context.json( { - error: "unsupported_grant_type", - error_description: "Unsupported grant type", + ...token.toApi(), + expires_in: token.data.expiresAt + ? Math.floor( + (new Date(token.data.expiresAt).getTime() - + Date.now()) / + 1000, + ) + : null, + refresh_token: null, }, - 401, + 200, ); }, ); diff --git a/packages/api/routes/api/v1/apps/index.ts b/packages/api/routes/api/v1/apps/index.ts index 0f2dbd2b..818a93b1 100644 --- a/packages/api/routes/api/v1/apps/index.ts +++ b/packages/api/routes/api/v1/apps/index.ts @@ -5,7 +5,6 @@ import { import { ApiError } from "@versia-server/kit"; import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api"; import { Application } from "@versia-server/kit/db"; -import { randomUUIDv7 } from "bun"; import { describeRoute, resolver, validator } from "hono-openapi"; import { z } from "zod/v4"; import { randomString } from "@/math"; @@ -64,14 +63,13 @@ export default apiRoute((app) => context.req.valid("json"); const app = await Application.insert({ - id: randomUUIDv7(), + id: randomString(32, "base64url"), name: client_name, - redirectUri: Array.isArray(redirect_uris) - ? redirect_uris.join("\n") - : redirect_uris, - scopes, + redirectUris: Array.isArray(redirect_uris) + ? redirect_uris + : [redirect_uris], + scopes: scopes.split(" "), website: website || undefined, - clientId: randomString(32, "base64url"), secret: randomString(64, "base64url"), }); diff --git a/packages/api/routes/api/v1/sso/index.ts b/packages/api/routes/api/v1/sso/index.ts index 86baa4b0..c1fb1af3 100644 --- a/packages/api/routes/api/v1/sso/index.ts +++ b/packages/api/routes/api/v1/sso/index.ts @@ -6,15 +6,9 @@ import { Application, db } from "@versia-server/kit/db"; import { OpenIdLoginFlows } from "@versia-server/kit/tables"; import { randomUUIDv7 } from "bun"; import { describeRoute, resolver, validator } from "hono-openapi"; -import { - calculatePKCECodeChallenge, - generateRandomCodeVerifier, -} from "oauth4webapi"; +import * as client from "openid-client"; import { z } from "zod/v4"; -import { - oauthDiscoveryRequest, - oauthRedirectUri, -} from "../../../../plugins/openid/utils.ts"; +import { oauthRedirectUri } from "@/lib"; export default apiRoute((app) => { app.get( @@ -105,9 +99,24 @@ export default apiRoute((app) => { ); } - const authServer = await oauthDiscoveryRequest(new URL(issuer.url)); + const oidcConfig = await client.discovery( + issuer.url, + issuer.client_id, + issuer.client_secret, + ); + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = + await client.calculatePKCECodeChallenge(codeVerifier); - const codeVerifier = generateRandomCodeVerifier(); + const parameters: Record = { + scope: "openid profile email", + code_challenge: codeChallenge, + code_challenge_method: "S256", + }; + + if (!oidcConfig.serverMetadata().supportsPKCE()) { + parameters.state = client.randomState(); + } const redirectUri = oauthRedirectUri( context.get("config").http.base_url, @@ -115,15 +124,14 @@ export default apiRoute((app) => { ); const application = await Application.insert({ - id: randomUUIDv7(), - clientId: + id: user.id + Buffer.from( crypto.getRandomValues(new Uint8Array(32)), ).toString("base64"), name: "Versia", - redirectUri: redirectUri.toString(), - scopes: "openid profile email", + redirectUris: [redirectUri.href], + scopes: ["openid", "profile", "email"], secret: "", }); @@ -134,30 +142,28 @@ export default apiRoute((app) => { .values({ id: randomUUIDv7(), codeVerifier, + state: parameters.state, issuerId, applicationId: application.id, }) .returning() )[0]; - const codeChallenge = - await calculatePKCECodeChallenge(codeVerifier); + parameters.redirect_uri = `${oauthRedirectUri( + config.http.base_url, + issuerId, + )}?${new URLSearchParams({ + flow: newFlow.id, + link: "true", + user_id: user.id, + })}`; - return context.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: `${redirectUri}?${new URLSearchParams({ - flow: newFlow.id, - link: "true", - user_id: user.id, - })}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString()}`, + const redirectTo = client.buildAuthorizationUrl( + oidcConfig, + parameters, ); + + return context.redirect(redirectTo); }, ); }); diff --git a/packages/api/routes/well-known/jwks.test.ts b/packages/api/routes/well-known/jwks.test.ts deleted file mode 100644 index 2d97acf5..00000000 --- a/packages/api/routes/well-known/jwks.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { Application } from "@versia-server/kit/db"; -import { fakeRequest } from "@versia-server/tests"; -import { randomUUIDv7 } from "bun"; - -const application = await Application.insert({ - id: randomUUIDv7(), - clientId: "test-client-id", - redirectUri: "https://example.com/callback", - scopes: "openid profile email", - secret: "test-secret", - name: "Test Application", -}); - -afterAll(async () => { - await application.delete(); -}); - -describe("/.well-known/jwks", () => { - test("should return JWK set with valid inputs", async () => { - const response = await fakeRequest("/.well-known/jwks", { - method: "GET", - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.keys).toHaveLength(1); - expect(body.keys[0].kty).toBe("OKP"); - expect(body.keys[0].use).toBe("sig"); - expect(body.keys[0].alg).toBe("EdDSA"); - expect(body.keys[0].kid).toBe("1"); - expect(body.keys[0].crv).toBe("Ed25519"); - expect(body.keys[0].x).toBeString(); - }); -}); diff --git a/packages/api/routes/well-known/jwks.ts b/packages/api/routes/well-known/jwks.ts deleted file mode 100644 index a8b182a3..00000000 --- a/packages/api/routes/well-known/jwks.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { config } from "@versia-server/config"; -import { apiRoute, auth } from "@versia-server/kit/api"; -import { describeRoute, resolver } from "hono-openapi"; -import { exportJWK } from "jose"; -import { z } from "zod/v4"; - -export default apiRoute((app) => { - app.get( - "/.well-known/jwks", - describeRoute({ - summary: "JWK Set", - tags: ["OpenID"], - responses: { - 200: { - description: "JWK Set", - content: { - "application/json": { - schema: resolver( - z.object({ - keys: z.array( - z.object({ - kty: z.string().optional(), - use: z.string(), - alg: z.string(), - kid: z.string(), - crv: z.string().optional(), - x: z.string().optional(), - y: z.string().optional(), - }), - ), - }), - ), - }, - }, - }, - }, - }), - auth({ - auth: false, - }), - async (context) => { - const jwk = await exportJWK(config.authentication.keys.private); - - // Remove the private key 💀 - jwk.d = undefined; - - return context.json( - { - keys: [ - { - ...jwk, - use: "sig", - alg: "EdDSA", - kid: "1", - }, - ], - }, - 200, - ); - }, - ); -}); diff --git a/packages/api/routes/well-known/openid-configuration/index.ts b/packages/api/routes/well-known/openid-configuration/index.ts deleted file mode 100644 index 9917997b..00000000 --- a/packages/api/routes/well-known/openid-configuration/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { config } from "@versia-server/config"; -import { apiRoute } from "@versia-server/kit/api"; -import { describeRoute, resolver } from "hono-openapi"; -import { z } from "zod/v4"; - -export default apiRoute((app) => - app.get( - "/.well-known/openid-configuration", - describeRoute({ - summary: "OpenID Configuration", - tags: ["OpenID"], - responses: { - 200: { - description: "OpenID Configuration", - content: { - "application/json": { - schema: resolver( - z.object({ - issuer: z.string(), - authorization_endpoint: z.string(), - token_endpoint: z.string(), - userinfo_endpoint: z.string(), - jwks_uri: z.string(), - response_types_supported: z.array( - z.string(), - ), - subject_types_supported: z.array( - z.string(), - ), - id_token_signing_alg_values_supported: - z.array(z.string()), - scopes_supported: z.array(z.string()), - token_endpoint_auth_methods_supported: - z.array(z.string()), - claims_supported: z.array(z.string()), - }), - ), - }, - }, - }, - }, - }), - (context) => { - const baseUrl = config.http.base_url; - return context.json( - { - issuer: baseUrl.origin.toString(), - authorization_endpoint: `${baseUrl.origin}/oauth/authorize`, - token_endpoint: `${baseUrl.origin}/oauth/token`, - userinfo_endpoint: `${baseUrl.origin}/api/v1/accounts/verify_credentials`, - jwks_uri: `${baseUrl.origin}/.well-known/jwks`, - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["EdDSA"], - scopes_supported: ["openid", "profile", "email"], - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - ], - claims_supported: ["sub"], - }, - 200, - ); - }, - ), -); diff --git a/packages/config/index.ts b/packages/config/index.ts index f4b50b96..8b54e7f0 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -799,7 +799,7 @@ export const ConfigSchema = z z.strictObject({ name: z.string().min(1), id: z.string().min(1), - url: z.string().min(1), + url, client_id: z.string().min(1), client_secret: sensitiveString, icon: url.optional(), diff --git a/packages/kit/api.ts b/packages/kit/api.ts index f31e6a7f..876b6f80 100644 --- a/packages/kit/api.ts +++ b/packages/kit/api.ts @@ -169,8 +169,8 @@ export const auth = (options: { const auth: AuthData = { token, - application: token?.data.application - ? new Application(token?.data.application) + application: token?.data.client + ? new Application(token?.data.client) : null, user: (await token?.getUser()) ?? null, }; diff --git a/packages/kit/db/application.ts b/packages/kit/db/application.ts index 15314c66..84920d41 100644 --- a/packages/kit/db/application.ts +++ b/packages/kit/db/application.ts @@ -12,13 +12,13 @@ import { } from "drizzle-orm"; import type { z } from "zod/v4"; import { db } from "../tables/db.ts"; -import { Applications } from "../tables/schema.ts"; +import { Clients } from "../tables/schema.ts"; import { BaseInterface } from "./base.ts"; import { Token } from "./token.ts"; -type ApplicationType = InferSelectModel; +type ApplicationType = InferSelectModel; -export class Application extends BaseInterface { +export class Application extends BaseInterface { public static $type: ApplicationType; public async reload(): Promise { @@ -36,18 +36,18 @@ export class Application extends BaseInterface { return null; } - return await Application.fromSql(eq(Applications.id, id)); + return await Application.fromSql(eq(Clients.id, id)); } public static async fromIds(ids: string[]): Promise { - return await Application.manyFromSql(inArray(Applications.id, ids)); + return await Application.manyFromSql(inArray(Clients.id, ids)); } public static async fromSql( sql: SQL | undefined, - orderBy: SQL | undefined = desc(Applications.id), + orderBy: SQL | undefined = desc(Clients.id), ): Promise { - const found = await db.query.Applications.findFirst({ + const found = await db.query.Clients.findFirst({ where: sql, orderBy, }); @@ -60,12 +60,12 @@ export class Application extends BaseInterface { public static async manyFromSql( sql: SQL | undefined, - orderBy: SQL | undefined = desc(Applications.id), + orderBy: SQL | undefined = desc(Clients.id), limit?: number, offset?: number, - extra?: Parameters[0], + extra?: Parameters[0], ): Promise { - const found = await db.query.Applications.findMany({ + const found = await db.query.Clients.findMany({ where: sql, orderBy, limit, @@ -81,22 +81,20 @@ export class Application extends BaseInterface { ): Promise { const result = await Token.fromAccessToken(token); - return result?.data.application - ? new Application(result.data.application) - : null; + return result?.data.client ? new Application(result.data.client) : null; } public static fromClientId(clientId: string): Promise { - return Application.fromSql(eq(Applications.clientId, clientId)); + return Application.fromSql(eq(Clients.id, clientId)); } public async update( newApplication: Partial, ): Promise { await db - .update(Applications) + .update(Clients) .set(newApplication) - .where(eq(Applications.id, this.id)); + .where(eq(Clients.id, this.id)); const updated = await Application.fromId(this.data.id); @@ -114,18 +112,16 @@ export class Application extends BaseInterface { public async delete(ids?: string[]): Promise { if (Array.isArray(ids)) { - await db.delete(Applications).where(inArray(Applications.id, ids)); + await db.delete(Clients).where(inArray(Clients.id, ids)); } else { - await db.delete(Applications).where(eq(Applications.id, this.id)); + await db.delete(Clients).where(eq(Clients.id, this.id)); } } public static async insert( - data: InferInsertModel, + data: InferInsertModel, ): Promise { - const inserted = ( - await db.insert(Applications).values(data).returning() - )[0]; + const inserted = (await db.insert(Clients).values(data).returning())[0]; const application = await Application.fromId(inserted.id); @@ -144,9 +140,9 @@ export class Application extends BaseInterface { return { name: this.data.name, website: this.data.website, - scopes: this.data.scopes.split(" "), - redirect_uri: this.data.redirectUri, - redirect_uris: this.data.redirectUri.split("\n"), + scopes: this.data.scopes, + redirect_uri: this.data.redirectUris.join(" "), + redirect_uris: this.data.redirectUris, }; } @@ -154,12 +150,12 @@ export class Application extends BaseInterface { return { name: this.data.name, website: this.data.website, - client_id: this.data.clientId, + client_id: this.data.id, client_secret: this.data.secret, client_secret_expires_at: "0", - scopes: this.data.scopes.split(" "), - redirect_uri: this.data.redirectUri, - redirect_uris: this.data.redirectUri.split("\n"), + scopes: this.data.scopes, + redirect_uri: this.data.redirectUris.join(" "), + redirect_uris: this.data.redirectUris, }; } } diff --git a/packages/kit/db/index.ts b/packages/kit/db/index.ts index 1cc2359d..67f52bcf 100644 --- a/packages/kit/db/index.ts +++ b/packages/kit/db/index.ts @@ -12,4 +12,4 @@ export { Relationship } from "./relationship.ts"; export { Role } from "./role.ts"; export { Timeline } from "./timeline.ts"; export { Token } from "./token.ts"; -export { User } from "./user.ts"; +export { transformOutputToUserWithRelations, User } from "./user.ts"; diff --git a/packages/kit/db/token.ts b/packages/kit/db/token.ts index fdfd9e43..7fd4620d 100644 --- a/packages/kit/db/token.ts +++ b/packages/kit/db/token.ts @@ -15,7 +15,7 @@ import { BaseInterface } from "./base.ts"; import { User } from "./user.ts"; type TokenType = InferSelectModel & { - application: typeof Application.$type | null; + client: typeof Application.$type; }; export class Token extends BaseInterface { @@ -51,7 +51,7 @@ export class Token extends BaseInterface { where: sql, orderBy, with: { - application: true, + client: true, }, }); @@ -74,7 +74,7 @@ export class Token extends BaseInterface { limit, offset, with: { - application: true, + client: true, ...extra?.with, }, }); @@ -159,7 +159,7 @@ export class Token extends BaseInterface { return { access_token: this.data.accessToken, token_type: "Bearer", - scope: this.data.scope, + scope: this.data.scopes.join(" "), created_at: Math.floor( new Date(this.data.createdAt).getTime() / 1000, ), diff --git a/packages/kit/db/user.ts b/packages/kit/db/user.ts index 30130e32..4551de82 100644 --- a/packages/kit/db/user.ts +++ b/packages/kit/db/user.ts @@ -77,6 +77,7 @@ export const userRelations = { }, } as const; +// TODO: Remove this function and use what drizzle outputs directly instead of transforming it export const transformOutputToUserWithRelations = ( user: Omit, "endpoints"> & { followerCount: unknown; @@ -525,15 +526,15 @@ export class User extends BaseInterface { providers: { id: string; name: string; - url: string; + url: ProxiableUrl; icon?: ProxiableUrl; }[], ): Promise< { id: string; name: string; - url: string; - icon?: string | undefined; + url: ProxiableUrl; + icon?: ProxiableUrl; server_id: string; }[] > { @@ -556,7 +557,7 @@ export class User extends BaseInterface { id: issuer.id, name: issuer.name, url: issuer.url, - icon: issuer.icon?.proxied, + icon: issuer.icon, server_id: account.serverId, }; }) diff --git a/packages/kit/tables/migrations/0051_stiff_morbius.sql b/packages/kit/tables/migrations/0051_stiff_morbius.sql new file mode 100644 index 00000000..23745656 --- /dev/null +++ b/packages/kit/tables/migrations/0051_stiff_morbius.sql @@ -0,0 +1,46 @@ +CREATE TABLE "AuthorizationCodes" ( + "code" text PRIMARY KEY NOT NULL, + "scopes" text[] DEFAULT ARRAY[]::text[] NOT NULL, + "redirect_uri" text, + "expires_at" timestamp(3) NOT NULL, + "created_at" timestamp(3) DEFAULT now() NOT NULL, + "code_challenge" text, + "code_challenge_method" text, + "userId" uuid NOT NULL, + "clientId" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "Tokens" RENAME COLUMN "applicationId" TO "clientId";--> statement-breakpoint +--ALTER TABLE "Notes" DROP CONSTRAINT "Notes_applicationId_Applications_id_fk"; +--> statement-breakpoint +--ALTER TABLE "OpenIdLoginFlows" DROP CONSTRAINT "OpenIdLoginFlows_applicationId_Applications_id_fk"; +--> statement-breakpoint +--ALTER TABLE "Tokens" DROP CONSTRAINT "Tokens_applicationId_Applications_id_fk"; +--> statement-breakpoint +DROP INDEX "Applications_client_id_index";--> statement-breakpoint +ALTER TABLE "Applications" ADD PRIMARY KEY ("client_id");--> statement-breakpoint +ALTER TABLE "Applications" ALTER COLUMN "scopes" SET DATA TYPE text[] USING (string_to_array("scopes", ' ')::text[]);--> statement-breakpoint +ALTER TABLE "Applications" ALTER COLUMN "scopes" SET DEFAULT ARRAY[]::text[];--> statement-breakpoint +ALTER TABLE "Notes" ALTER COLUMN "applicationId" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ALTER COLUMN "applicationId" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "Applications" ADD COLUMN "redirect_uris" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "state" text;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_state" text;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_redirect_uri" text;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ADD COLUMN "client_scopes" text[];--> statement-breakpoint +ALTER TABLE "Tokens" ADD COLUMN "scopes" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint +ALTER TABLE "AuthorizationCodes" ADD CONSTRAINT "AuthorizationCodes_userId_Users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."Users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "AuthorizationCodes" ADD CONSTRAINT "AuthorizationCodes_clientId_Applications_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "Notes" ADD CONSTRAINT "Notes_applicationId_Applications_client_id_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."Applications"("client_id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "OpenIdLoginFlows" ADD CONSTRAINT "OpenIdLoginFlows_applicationId_Applications_client_id_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "Tokens" ALTER COLUMN "clientId" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "Tokens" ADD CONSTRAINT "Tokens_clientId_Applications_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."Applications"("client_id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "Applications" DROP COLUMN "id";--> statement-breakpoint +ALTER TABLE "Applications" DROP COLUMN "vapid_key";--> statement-breakpoint +ALTER TABLE "Applications" DROP COLUMN "redirect_uri";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "token_type";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "scope";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "code";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "client_id";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "redirect_uri";--> statement-breakpoint +ALTER TABLE "Tokens" DROP COLUMN "id_token"; diff --git a/packages/kit/tables/migrations/meta/0051_snapshot.json b/packages/kit/tables/migrations/meta/0051_snapshot.json new file mode 100644 index 00000000..6f8fb3cd --- /dev/null +++ b/packages/kit/tables/migrations/meta/0051_snapshot.json @@ -0,0 +1,2439 @@ +{ + "id": "62ba1f05-306f-4b31-a4c0-935d6946634e", + "prevId": "13fdf09c-59ef-4e1e-9e59-f87676154810", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.AuthorizationCodes": { + "name": "AuthorizationCodes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "AuthorizationCodes_userId_Users_id_fk": { + "name": "AuthorizationCodes_userId_Users_id_fk", + "tableFrom": "AuthorizationCodes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "AuthorizationCodes_clientId_Applications_client_id_fk": { + "name": "AuthorizationCodes_clientId_Applications_client_id_fk", + "tableFrom": "AuthorizationCodes", + "tableTo": "Applications", + "columnsFrom": ["clientId"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Challenges": { + "name": "Challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "NOW() + INTERVAL '5 minutes'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Applications": { + "name": "Applications", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmojiToNote": { + "name": "EmojiToNote", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToNote_emojiId_noteId_index": { + "name": "EmojiToNote_emojiId_noteId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToNote_noteId_index": { + "name": "EmojiToNote_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToNote_emojiId_Emojis_id_fk": { + "name": "EmojiToNote_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToNote_noteId_Notes_id_fk": { + "name": "EmojiToNote_noteId_Notes_id_fk", + "tableFrom": "EmojiToNote", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.EmojiToUser": { + "name": "EmojiToUser", + "schema": "", + "columns": { + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "EmojiToUser_emojiId_userId_index": { + "name": "EmojiToUser_emojiId_userId_index", + "columns": [ + { + "expression": "emojiId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "EmojiToUser_userId_index": { + "name": "EmojiToUser_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "EmojiToUser_emojiId_Emojis_id_fk": { + "name": "EmojiToUser_emojiId_Emojis_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "EmojiToUser_userId_Users_id_fk": { + "name": "EmojiToUser_userId_Users_id_fk", + "tableFrom": "EmojiToUser", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Emojis": { + "name": "Emojis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shortcode": { + "name": "shortcode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mediaId": { + "name": "mediaId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visible_in_picker": { + "name": "visible_in_picker", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Emojis_mediaId_Medias_id_fk": { + "name": "Emojis_mediaId_Medias_id_fk", + "tableFrom": "Emojis", + "tableTo": "Medias", + "columnsFrom": ["mediaId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Emojis_instanceId_Instances_id_fk": { + "name": "Emojis_instanceId_Instances_id_fk", + "tableFrom": "Emojis", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Emojis_ownerId_Users_id_fk": { + "name": "Emojis_ownerId_Users_id_fk", + "tableFrom": "Emojis", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.FilterKeywords": { + "name": "FilterKeywords", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "filterId": { + "name": "filterId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "whole_word": { + "name": "whole_word", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "FilterKeywords_filterId_Filters_id_fk": { + "name": "FilterKeywords_filterId_Filters_id_fk", + "tableFrom": "FilterKeywords", + "tableTo": "Filters", + "columnsFrom": ["filterId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Filters": { + "name": "Filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filter_action": { + "name": "filter_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Filters_userId_Users_id_fk": { + "name": "Filters_userId_Users_id_fk", + "tableFrom": "Filters", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Flags": { + "name": "Flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Flags_noteId_Notes_id_fk": { + "name": "Flags_noteId_Notes_id_fk", + "tableFrom": "Flags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Flags_userId_Users_id_fk": { + "name": "Flags_userId_Users_id_fk", + "tableFrom": "Flags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Instances": { + "name": "Instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'versia'" + }, + "inbox": { + "name": "inbox", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "extensions": { + "name": "extensions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Likes": { + "name": "Likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "likerId": { + "name": "likerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "likedId": { + "name": "likedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Likes_likerId_Users_id_fk": { + "name": "Likes_likerId_Users_id_fk", + "tableFrom": "Likes", + "tableTo": "Users", + "columnsFrom": ["likerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Likes_likedId_Notes_id_fk": { + "name": "Likes_likedId_Notes_id_fk", + "tableFrom": "Likes", + "tableTo": "Notes", + "columnsFrom": ["likedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Likes_uri_unique": { + "name": "Likes_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Markers": { + "name": "Markers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "notificationId": { + "name": "notificationId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timeline": { + "name": "timeline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Markers_noteId_Notes_id_fk": { + "name": "Markers_noteId_Notes_id_fk", + "tableFrom": "Markers", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_notificationId_Notifications_id_fk": { + "name": "Markers_notificationId_Notifications_id_fk", + "tableFrom": "Markers", + "tableTo": "Notifications", + "columnsFrom": ["notificationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Markers_userId_Users_id_fk": { + "name": "Markers_userId_Users_id_fk", + "tableFrom": "Markers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Medias": { + "name": "Medias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "original_content": { + "name": "original_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "thumbnail": { + "name": "thumbnail", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blurhash": { + "name": "blurhash", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.MediasToNote": { + "name": "MediasToNote", + "schema": "", + "columns": { + "mediaId": { + "name": "mediaId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "MediasToNote_mediaId_index": { + "name": "MediasToNote_mediaId_index", + "columns": [ + { + "expression": "mediaId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "MediasToNote_noteId_index": { + "name": "MediasToNote_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "MediasToNote_mediaId_Medias_id_fk": { + "name": "MediasToNote_mediaId_Medias_id_fk", + "tableFrom": "MediasToNote", + "tableTo": "Medias", + "columnsFrom": ["mediaId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "MediasToNote_noteId_Notes_id_fk": { + "name": "MediasToNote_noteId_Notes_id_fk", + "tableFrom": "MediasToNote", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ModNotes": { + "name": "ModNotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModNotes_noteId_Notes_id_fk": { + "name": "ModNotes_noteId_Notes_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_userId_Users_id_fk": { + "name": "ModNotes_userId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModNotes_modId_Users_id_fk": { + "name": "ModNotes_modId_Users_id_fk", + "tableFrom": "ModNotes", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ModTags": { + "name": "ModTags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "modId": { + "name": "modId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ModTags_noteId_Notes_id_fk": { + "name": "ModTags_noteId_Notes_id_fk", + "tableFrom": "ModTags", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_userId_Users_id_fk": { + "name": "ModTags_userId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "ModTags_modId_Users_id_fk": { + "name": "ModTags_modId_Users_id_fk", + "tableFrom": "ModTags", + "tableTo": "Users", + "columnsFrom": ["modId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.NoteToMentions": { + "name": "NoteToMentions", + "schema": "", + "columns": { + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "NoteToMentions_noteId_userId_index": { + "name": "NoteToMentions_noteId_userId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "NoteToMentions_userId_index": { + "name": "NoteToMentions_userId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "NoteToMentions_noteId_Notes_id_fk": { + "name": "NoteToMentions_noteId_Notes_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "NoteToMentions_userId_Users_id_fk": { + "name": "NoteToMentions_userId_Users_id_fk", + "tableFrom": "NoteToMentions", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notes": { + "name": "Notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reblogId": { + "name": "reblogId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text/plain'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reblog_count": { + "name": "reblog_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "like_count": { + "name": "like_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "replyId": { + "name": "replyId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quoteId": { + "name": "quoteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "spoiler_text": { + "name": "spoiler_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_source": { + "name": "content_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": { + "Notes_authorId_Users_id_fk": { + "name": "Notes_authorId_Users_id_fk", + "tableFrom": "Notes", + "tableTo": "Users", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_reblogId_Notes_id_fk": { + "name": "Notes_reblogId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["reblogId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_replyId_Notes_id_fk": { + "name": "Notes_replyId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["replyId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_quoteId_Notes_id_fk": { + "name": "Notes_quoteId_Notes_id_fk", + "tableFrom": "Notes", + "tableTo": "Notes", + "columnsFrom": ["quoteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notes_applicationId_Applications_client_id_fk": { + "name": "Notes_applicationId_Applications_client_id_fk", + "tableFrom": "Notes", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["client_id"], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Notes_uri_unique": { + "name": "Notes_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Notifications": { + "name": "Notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notifiedId": { + "name": "notifiedId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "Notifications_notifiedId_Users_id_fk": { + "name": "Notifications_notifiedId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["notifiedId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_accountId_Users_id_fk": { + "name": "Notifications_accountId_Users_id_fk", + "tableFrom": "Notifications", + "tableTo": "Users", + "columnsFrom": ["accountId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Notifications_noteId_Notes_id_fk": { + "name": "Notifications_noteId_Notes_id_fk", + "tableFrom": "Notifications", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OpenIdAccounts": { + "name": "OpenIdAccounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdAccounts_userId_Users_id_fk": { + "name": "OpenIdAccounts_userId_Users_id_fk", + "tableFrom": "OpenIdAccounts", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OpenIdLoginFlows": { + "name": "OpenIdLoginFlows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_state": { + "name": "client_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_redirect_uri": { + "name": "client_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_scopes": { + "name": "client_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "applicationId": { + "name": "applicationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issuer_id": { + "name": "issuer_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "OpenIdLoginFlows_applicationId_Applications_client_id_fk": { + "name": "OpenIdLoginFlows_applicationId_Applications_client_id_fk", + "tableFrom": "OpenIdLoginFlows", + "tableTo": "Applications", + "columnsFrom": ["applicationId"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.PushSubscriptions": { + "name": "PushSubscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_secret": { + "name": "auth_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alerts": { + "name": "alerts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "tokenId": { + "name": "tokenId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "PushSubscriptions_tokenId_Tokens_id_fk": { + "name": "PushSubscriptions_tokenId_Tokens_id_fk", + "tableFrom": "PushSubscriptions", + "tableTo": "Tokens", + "columnsFrom": ["tokenId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "PushSubscriptions_tokenId_unique": { + "name": "PushSubscriptions_tokenId_unique", + "nullsNotDistinct": false, + "columns": ["tokenId"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Reaction": { + "name": "Reaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emojiId": { + "name": "emojiId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emoji_text": { + "name": "emoji_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Reaction_emojiId_Emojis_id_fk": { + "name": "Reaction_emojiId_Emojis_id_fk", + "tableFrom": "Reaction", + "tableTo": "Emojis", + "columnsFrom": ["emojiId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Reaction_noteId_Notes_id_fk": { + "name": "Reaction_noteId_Notes_id_fk", + "tableFrom": "Reaction", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Reaction_authorId_Users_id_fk": { + "name": "Reaction_authorId_Users_id_fk", + "tableFrom": "Reaction", + "tableTo": "Users", + "columnsFrom": ["authorId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Reaction_uri_unique": { + "name": "Reaction_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Relationships": { + "name": "Relationships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subjectId": { + "name": "subjectId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "following": { + "name": "following", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "showing_reblogs": { + "name": "showing_reblogs", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "notifying": { + "name": "notifying", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "blocking": { + "name": "blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting": { + "name": "muting", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "muting_notifications": { + "name": "muting_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "requested": { + "name": "requested", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "domain_blocking": { + "name": "domain_blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "endorsed": { + "name": "endorsed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Relationships_ownerId_Users_id_fk": { + "name": "Relationships_ownerId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["ownerId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Relationships_subjectId_Users_id_fk": { + "name": "Relationships_subjectId_Users_id_fk", + "tableFrom": "Relationships", + "tableTo": "Users", + "columnsFrom": ["subjectId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.RoleToUsers": { + "name": "RoleToUsers", + "schema": "", + "columns": { + "roleId": { + "name": "roleId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "RoleToUsers_roleId_Roles_id_fk": { + "name": "RoleToUsers_roleId_Roles_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Roles", + "columnsFrom": ["roleId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "RoleToUsers_userId_Users_id_fk": { + "name": "RoleToUsers_userId_Users_id_fk", + "tableFrom": "RoleToUsers", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Roles": { + "name": "Roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Tokens": { + "name": "Tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Tokens_userId_Users_id_fk": { + "name": "Tokens_userId_Users_id_fk", + "tableFrom": "Tokens", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "Tokens_clientId_Applications_client_id_fk": { + "name": "Tokens_clientId_Applications_client_id_fk", + "tableFrom": "Tokens", + "tableTo": "Applications", + "columnsFrom": ["clientId"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.UserToPinnedNotes": { + "name": "UserToPinnedNotes", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "noteId": { + "name": "noteId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UserToPinnedNotes_userId_noteId_index": { + "name": "UserToPinnedNotes_userId_noteId_index", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UserToPinnedNotes_noteId_index": { + "name": "UserToPinnedNotes_noteId_index", + "columns": [ + { + "expression": "noteId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "UserToPinnedNotes_userId_Users_id_fk": { + "name": "UserToPinnedNotes_userId_Users_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "UserToPinnedNotes_noteId_Notes_id_fk": { + "name": "UserToPinnedNotes_noteId_Notes_id_fk", + "tableFrom": "UserToPinnedNotes", + "tableTo": "Notes", + "columnsFrom": ["noteId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Users": { + "name": "Users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verification_token": { + "name": "email_verification_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "endpoints": { + "name": "endpoints", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "avatarId": { + "name": "avatarId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "headerId": { + "name": "headerId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "follower_count": { + "name": "follower_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "following_count": { + "name": "following_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status_count": { + "name": "status_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_discoverable": { + "name": "is_discoverable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_hiding_collections": { + "name": "is_hiding_collections", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sanctions": { + "name": "sanctions", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instanceId": { + "name": "instanceId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "disable_automoderation": { + "name": "disable_automoderation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "Users_uri_index": { + "name": "Users_uri_index", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_username_index": { + "name": "Users_username_index", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "Users_email_index": { + "name": "Users_email_index", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Users_avatarId_Medias_id_fk": { + "name": "Users_avatarId_Medias_id_fk", + "tableFrom": "Users", + "tableTo": "Medias", + "columnsFrom": ["avatarId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "Users_headerId_Medias_id_fk": { + "name": "Users_headerId_Medias_id_fk", + "tableFrom": "Users", + "tableTo": "Medias", + "columnsFrom": ["headerId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "Users_instanceId_Instances_id_fk": { + "name": "Users_instanceId_Instances_id_fk", + "tableFrom": "Users", + "tableTo": "Instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Users_uri_unique": { + "name": "Users_uri_unique", + "nullsNotDistinct": false, + "columns": ["uri"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/kit/tables/migrations/meta/_journal.json b/packages/kit/tables/migrations/meta/_journal.json index b4ba6c21..180cdd3f 100644 --- a/packages/kit/tables/migrations/meta/_journal.json +++ b/packages/kit/tables/migrations/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1746368175263, "tag": "0050_thick_lester", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1755729662013, + "tag": "0051_stiff_morbius", + "breakpoints": true } ] } diff --git a/packages/kit/tables/schema.ts b/packages/kit/tables/schema.ts index 7f0c8e17..5d243179 100644 --- a/packages/kit/tables/schema.ts +++ b/packages/kit/tables/schema.ts @@ -28,6 +28,7 @@ import { import type { z } from "zod/v4"; const createdAt = () => + // TODO: Change mode to Date timestamp("created_at", { precision: 3, mode: "string" }) .defaultNow() .notNull(); @@ -39,7 +40,7 @@ const updatedAt = () => const uri = () => text("uri").unique(); -const id = () => uuid("id").primaryKey().notNull(); +const id = () => uuid("id").primaryKey(); export const Challenges = pgTable("Challenges", { id: id(), @@ -308,47 +309,41 @@ export const RelationshipsRelations = relations(Relationships, ({ one }) => ({ }), })); -export const Applications = pgTable( - "Applications", - { - id: id(), - name: text("name").notNull(), - website: text("website"), - vapidKey: text("vapid_key"), - clientId: text("client_id").notNull(), - secret: text("secret").notNull(), - scopes: text("scopes").notNull(), - redirectUri: text("redirect_uri").notNull(), - }, - (table) => [uniqueIndex().on(table.clientId)], -); +export const Clients = pgTable("Applications", { + id: text("client_id").primaryKey(), + secret: text("secret").notNull(), + redirectUris: text("redirect_uris") + .array() + .notNull() + .default(sql`ARRAY[]::text[]`), + scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`), + name: text("name").notNull(), + website: text("website"), +}); -export const ApplicationsRelations = relations(Applications, ({ many }) => ({ +export const ClientsRelations = relations(Clients, ({ many }) => ({ tokens: many(Tokens), loginFlows: many(OpenIdLoginFlows), })); export const Tokens = pgTable("Tokens", { id: id(), - tokenType: text("token_type").notNull(), - scope: text("scope").notNull(), + scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`), accessToken: text("access_token").notNull(), - code: text("code"), expiresAt: timestamp("expires_at", { precision: 3, mode: "string" }), createdAt: createdAt(), - clientId: text("client_id").notNull().default(""), - redirectUri: text("redirect_uri").notNull().default(""), - idToken: text("id_token"), userId: uuid("userId") .references(() => Users.id, { onDelete: "cascade", onUpdate: "cascade", }) .notNull(), - applicationId: uuid("applicationId").references(() => Applications.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), + clientId: text("clientId") + .references(() => Clients.id, { + onDelete: "cascade", + onUpdate: "cascade", + }) + .notNull(), }); export const TokensRelations = relations(Tokens, ({ one }) => ({ @@ -356,12 +351,51 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({ fields: [Tokens.userId], references: [Users.id], }), - application: one(Applications, { - fields: [Tokens.applicationId], - references: [Applications.id], + client: one(Clients, { + fields: [Tokens.clientId], + references: [Clients.id], }), })); +export const AuthorizationCodes = pgTable("AuthorizationCodes", { + code: text("code").primaryKey(), + scopes: text("scopes").array().notNull().default(sql`ARRAY[]::text[]`), + redirectUri: text("redirect_uri"), + expiresAt: timestamp("expires_at", { + precision: 3, + mode: "string", + }).notNull(), + createdAt: createdAt(), + codeChallenge: text("code_challenge"), + codeChallengeMethod: text("code_challenge_method"), + userId: uuid("userId") + .references(() => Users.id, { + onDelete: "cascade", + onUpdate: "cascade", + }) + .notNull(), + clientId: text("clientId") + .references(() => Clients.id, { + onDelete: "cascade", + onUpdate: "cascade", + }) + .notNull(), +}); + +export const AuthorizationCodesRelations = relations( + AuthorizationCodes, + ({ one }) => ({ + user: one(Users, { + fields: [AuthorizationCodes.userId], + references: [Users.id], + }), + client: one(Clients, { + fields: [AuthorizationCodes.clientId], + references: [Clients.id], + }), + }), +); + export const Medias = pgTable("Medias", { id: id(), content: jsonb("content") @@ -460,7 +494,7 @@ export const Notes = pgTable("Notes", { }), sensitive: boolean("sensitive").notNull().default(false), spoilerText: text("spoiler_text").default("").notNull(), - applicationId: uuid("applicationId").references(() => Applications.id, { + applicationId: text("applicationId").references(() => Clients.id, { onDelete: "set null", onUpdate: "cascade", }), @@ -494,9 +528,9 @@ export const NotesRelations = relations(Notes, ({ many, one }) => ({ references: [Notes.id], relationName: "NoteToQuotes", }), - application: one(Applications, { + application: one(Clients, { fields: [Notes.applicationId], - references: [Applications.id], + references: [Clients.id], }), quotes: many(Notes, { relationName: "NoteToQuotes", @@ -665,7 +699,11 @@ export const UsersRelations = relations(Users, ({ many, one }) => ({ export const OpenIdLoginFlows = pgTable("OpenIdLoginFlows", { id: id(), codeVerifier: text("code_verifier").notNull(), - applicationId: uuid("applicationId").references(() => Applications.id, { + state: text("state"), + clientState: text("client_state"), + clientRedirectUri: text("client_redirect_uri"), + clientScopes: text("client_scopes").array(), + applicationId: text("applicationId").references(() => Clients.id, { onDelete: "cascade", onUpdate: "cascade", }), @@ -675,9 +713,9 @@ export const OpenIdLoginFlows = pgTable("OpenIdLoginFlows", { export const OpenIdLoginFlowsRelations = relations( OpenIdLoginFlows, ({ one }) => ({ - application: one(Applications, { + application: one(Clients, { fields: [OpenIdLoginFlows.applicationId], - references: [Applications.id], + references: [Clients.id], }), }), ); diff --git a/packages/tests/index.ts b/packages/tests/index.ts index 96468b82..c64cefa4 100644 --- a/packages/tests/index.ts +++ b/packages/tests/index.ts @@ -1,7 +1,14 @@ import { mock } from "bun:test"; import { Client as VersiaClient } from "@versia/client"; import { config } from "@versia-server/config"; -import { db, Note, setupDatabase, Token, User } from "@versia-server/kit/db"; +import { + Application, + db, + Note, + setupDatabase, + Token, + User, +} from "@versia-server/kit/db"; import { searchManager } from "@versia-server/kit/search"; import { Notes, Users } from "@versia-server/kit/tables"; import { solveChallenge } from "altcha-lib"; @@ -43,15 +50,21 @@ export const generateClient = async ( dbToken: Token; } > => { + const application = await Application.insert({ + id: randomUUIDv7(), + name: "Versia", + redirectUris: [], + scopes: ["openid", "profile", "email"], + secret: "", + }); + const token = user ? await Token.insert({ id: randomUUIDv7(), accessToken: randomString(32, "hex"), - tokenType: "bearer", userId: user.id, - applicationId: null, - code: randomString(32, "hex"), - scope: "read write follow push", + clientId: application.id, + scopes: ["read", "write", "follow", "push"], }) : null; @@ -71,6 +84,7 @@ export const generateClient = async ( // @ts-expect-error This is REAL monkeypatching done by REAL programmers, BITCH! client[Symbol.asyncDispose] = async (): Promise => { await token?.delete(); + await application.delete(); }; // @ts-expect-error More monkeypatching @@ -97,6 +111,14 @@ export const getTestUsers = async ( const users: User[] = []; const passwords: string[] = []; + const application = await Application.insert({ + id: randomUUIDv7(), + name: "Versia", + redirectUris: [], + scopes: ["openid", "profile", "email"], + secret: "", + }); + for (let i = 0; i < count; i++) { const password = randomString(32, "hex"); @@ -119,9 +141,9 @@ export const getTestUsers = async ( accessToken: randomString(32, "hex"), tokenType: "bearer", userId: u.id, - applicationId: null, + clientId: application.id, code: randomString(32, "hex"), - scope: "read write follow push", + scopes: ["read", "write", "follow", "push"], })), ); @@ -140,6 +162,7 @@ export const getTestUsers = async ( users.map((u) => u.id), ), ); + await application.delete(); }, }; }; diff --git a/utils/bull-board.ts b/utils/bull-board.ts index 56603839..5abcbeb2 100644 --- a/utils/bull-board.ts +++ b/utils/bull-board.ts @@ -14,8 +14,7 @@ import { relationshipQueue } from "@versia-server/kit/queues/relationships"; import type { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { getCookie } from "hono/cookie"; -import { jwtVerify } from "jose"; -import { JOSEError, JWTExpired } from "jose/errors"; +import { verify } from "hono/jwt"; import type { HonoEnv } from "~/types/api"; import pkg from "../package.json" with { type: "json" }; @@ -58,38 +57,18 @@ export const applyToHono = (app: Hono): void => { throw new ApiError(401, "Missing JWT cookie"); } - const result = await jwtVerify( + const result = await verify( jwtCookie, config.authentication.keys.public, - { - algorithms: ["EdDSA"], - issuer: new URL(context.get("config").http.base_url).origin, - }, - ).catch((error) => { - if (error instanceof JOSEError) { - return error; - } + ); - throw error; - }); - - if (result instanceof JOSEError) { - if (result instanceof JWTExpired) { - throw new ApiError(401, "JWT has expired"); - } - - throw new ApiError(401, "Invalid JWT"); - } - - const { - payload: { sub }, - } = result; + const { sub } = result; if (!sub) { throw new ApiError(401, "Invalid JWT (no sub)"); } - const user = await User.fromId(sub); + const user = await User.fromId(sub as string); if (!user?.hasPermission(RolePermission.ManageInstanceFederation)) { throw new ApiError( diff --git a/utils/lib.ts b/utils/lib.ts index a5cb0dac..fe265bb8 100644 --- a/utils/lib.ts +++ b/utils/lib.ts @@ -9,3 +9,6 @@ export const mergeAndDeduplicate = ( (element, index, self) => index === self.findIndex((t) => t.id === element.id), ); + +export const oauthRedirectUri = (baseUrl: URL, issuer: string): URL => + new URL(`/oauth/sso/${issuer}/callback`, baseUrl);