From 3f37b24f5afca61494c18b9f1dfd7a620e96b69d Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:22:58 -0800 Subject: [PATCH 1/6] remove sign in with world id hasura infrastructure --- hasura/metadata/actions.graphql | 11 ----------- hasura/metadata/actions.yaml | 12 ------------ hasura/metadata/cron_triggers.yaml | 14 -------------- 3 files changed, 37 deletions(-) diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 011a789df..09fa6fd47 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -88,13 +88,6 @@ type Mutation { ): ResetAPIOutput } -type Mutation { - reset_client_secret( - app_id: String! - team_id: String! - ): ResetClientOutput -} - type Mutation { rotate_signer_key( app_id: String! @@ -255,10 +248,6 @@ input ChangeAppReportStatusInput { updates: [ChangeAppReportStatusUpdate!]! } -type ResetClientOutput { - client_secret: String! -} - type ResetAPIOutput { api_key: String! } diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 7166dae1d..0850867e9 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -159,17 +159,6 @@ actions: permissions: - role: user comment: Reset the given API key for the developer portal - - name: reset_client_secret - definition: - kind: synchronous - handler: "{{NEXT_API_URL}}/hasura/reset-client-secret" - headers: - - name: Authorization - value_from_env: INTERNAL_ENDPOINTS_SECRET - permissions: - - role: api_key - - role: user - comment: Reset the client secret for a Sign in with World ID application - name: rotate_signer_key definition: kind: synchronous @@ -391,7 +380,6 @@ custom_types: - name: ChangeAppReportStatusUpdate - name: ChangeAppReportStatusInput objects: - - name: ResetClientOutput - name: ResetAPIOutput - name: InviteTeamMembersOutput - name: PresignedPost diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index 07132985a..5ea3eb36b 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -26,20 +26,6 @@ - name: Authorization value_from_env: INTERNAL_ENDPOINTS_SECRET comment: "" -- name: Delete expired jwks - webhook: '{{NEXT_API_URL}}/_delete-jwks' - schedule: 0 * * * * - include_in_metadata: true - payload: {} - retry_conf: - num_retries: 1 - retry_interval_seconds: 10 - timeout_seconds: 60 - tolerance_seconds: 21600 - headers: - - name: Authorization - value_from_env: INTERNAL_ENDPOINTS_SECRET - comment: Schedules all expired JWKS for deletion by KMS - name: Rollup app stats webhook: '{{NEXT_API_URL}}/_rollup-app-stats' schedule: 0 * * * * From d64b4d6ca45c7aafe31bcbb78eb3a2fd8c62a3c1 Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:23:08 -0800 Subject: [PATCH 2/6] remove sign in with world id API and UI --- .../auxiliary/auxiliary-endpoints.spec.ts | 21 - web/.env.example | 3 - web/api/_delete-jwks/index.ts | 21 - .../graphql/get-membership.generated.ts | 71 -- .../graphql/get-membership.graphql | 11 - .../graphql/update-secret.generated.ts | 68 -- .../graphql/update-secret.graphql | 8 - web/api/hasura/reset-client-secret/index.ts | 104 --- .../graphql/delete-expired-jwks.generated.ts | 71 -- .../jwks/graphql/delete-expired-jwks.gql | 8 - ...tive-jwks-by-expiration-query.generated.ts | 71 -- .../fetch-active-jwks-by-expiration-query.gql | 10 - .../jwks/graphql/insert-jwk.generated.ts | 79 -- web/api/helpers/jwks/graphql/insert-jwk.gql | 17 - .../jwks/graphql/retrieve-jwk.generated.ts | 67 -- web/api/helpers/jwks/graphql/retrieve-jwk.gql | 7 - web/api/helpers/jwks/index.ts | 204 ----- web/api/helpers/jwts.ts | 110 --- web/api/helpers/kms.ts | 42 - .../fetch-app-secret-query.generated.ts | 75 -- .../oidc/graphql/fetch-app-secret-query.gql | 15 - .../oidc/graphql/fetch-oidc-app.generated.ts | 88 -- .../helpers/oidc/graphql/fetch-oidc-app.gql | 21 - .../graphql/insert-auth-code.generated.ts | 99 --- .../helpers/oidc/graphql/insert-auth-code.gql | 30 - web/api/helpers/oidc/index.ts | 240 ------ web/api/v1/jwks/graphql/get-jwks.generated.ts | 65 -- web/api/v1/jwks/graphql/get-jwks.graphql | 7 - web/api/v1/jwks/index.ts | 28 - .../graphql/fetch-nullifier.generated.ts | 60 -- .../authorize/graphql/fetch-nullifier.graphql | 5 - .../graphql/upsert-nullifier.generated.ts | 70 -- .../graphql/upsert-nullifier.graphql | 9 - web/api/v1/oidc/authorize/index.ts | 439 ---------- web/api/v1/oidc/introspect/index.ts | 75 -- web/api/v1/oidc/openid-configuration/index.ts | 38 - .../graphql/delete-auth-code.generated.ts | 95 --- .../token/graphql/delete-auth-code.graphql | 24 - .../graphql/fetch-redirect-count.generated.ts | 61 -- .../graphql/fetch-redirect-count.graphql | 5 - web/api/v1/oidc/token/index.ts | 244 ------ web/api/v1/oidc/userinfo/index.ts | 109 --- web/api/v1/oidc/validate/index.ts | 69 -- .../[appId]/sign-in-with-world-id/layout.tsx | 2 - .../[appId]/sign-in-with-world-id/page.tsx | 9 - .../proof-debugging/page.tsx | 2 - web/app/api/%5Fdelete-jwks/route.ts | 1 - .../api/hasura/reset-client-secret/route.ts | 1 - web/app/api/v1/jwks/route.ts | 1 - web/app/api/v1/oidc/authorize/route.ts | 1 - web/app/api/v1/oidc/introspect/route.ts | 1 - .../api/v1/oidc/openid-configuration/route.ts | 1 - web/app/api/v1/oidc/token/route.ts | 1 - web/app/api/v1/oidc/userinfo/route.ts | 1 - web/app/api/v1/oidc/validate/route.ts | 1 - web/lib/constants.ts | 6 - web/lib/urls.ts | 3 - web/next.config.mjs | 4 - .../ProofDebugging/page/index.tsx | 7 - .../AppId/SignInWithWorldId/error/error.tsx | 22 - .../AppId/SignInWithWorldId/layout/index.tsx | 10 - .../page/ClientInformation/Links/index.tsx | 101 --- .../Redirects/RedirectInput/index.tsx | 121 --- .../client/delete-redirect.generated.ts | 65 -- .../graphql/client/delete-redirect.graphql | 5 - .../client/fetch-redirect.generated.ts | 106 --- .../graphql/client/fetch-redirect.graphql | 12 - .../client/insert-redirect.generated.ts | 78 -- .../graphql/client/insert-redirect.graphql | 9 - .../client/update-redirect.generated.ts | 81 -- .../graphql/client/update-redirect.graphql | 9 - .../ClientInformation/Redirects/index.tsx | 181 ----- .../client/fetch-sign-in-action.generated.ts | 113 --- .../client/fetch-sign-in-action.graphql | 13 - .../graphql/client/reset-secret.generated.ts | 70 -- .../graphql/client/reset-secret.graphql | 5 - .../client/update-sign-in-action.generated.ts | 67 -- .../client/update-sign-in-action.graphql | 5 - .../page/ClientInformation/index.tsx | 198 ----- .../graphql/server/fetch-signin.generated.ts | 71 -- .../page/graphql/server/fetch-signin.graphql | 9 - .../AppId/SignInWithWorldId/page/index.tsx | 104 --- .../TeamId/Apps/AppId/layout/AppIdChrome.tsx | 20 - .../Members/List/PermissionsDialog/index.tsx | 10 - web/tests/api/__mocks__/jwks.mock.ts | 10 - web/tests/api/__mocks__/kms.mock.ts | 1 - web/tests/api/delete-jwks.test.ts | 112 --- web/tests/api/v1/oidc/authorize.test.ts | 375 --------- web/tests/api/v1/oidc/userinfo.test.ts | 46 -- web/tests/api/v1/oidc/validate.test.ts | 108 --- web/tests/integration/action.test.ts | 7 - web/tests/integration/app.test.ts | 128 --- web/tests/integration/jwks.test.ts | 92 --- web/tests/integration/oidc/authorize.test.ts | 151 ---- web/tests/integration/oidc/token.test.ts | 749 ------------------ web/tests/integration/team.test.ts | 6 +- web/tests/unit/check-flow-type.test.ts | 59 -- 97 files changed, 3 insertions(+), 6332 deletions(-) delete mode 100644 web/api/_delete-jwks/index.ts delete mode 100644 web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts delete mode 100644 web/api/hasura/reset-client-secret/graphql/get-membership.graphql delete mode 100644 web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts delete mode 100644 web/api/hasura/reset-client-secret/graphql/update-secret.graphql delete mode 100644 web/api/hasura/reset-client-secret/index.ts delete mode 100644 web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts delete mode 100644 web/api/helpers/jwks/graphql/delete-expired-jwks.gql delete mode 100644 web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts delete mode 100644 web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql delete mode 100644 web/api/helpers/jwks/graphql/insert-jwk.generated.ts delete mode 100644 web/api/helpers/jwks/graphql/insert-jwk.gql delete mode 100644 web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts delete mode 100644 web/api/helpers/jwks/graphql/retrieve-jwk.gql delete mode 100644 web/api/helpers/jwks/index.ts delete mode 100644 web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts delete mode 100644 web/api/helpers/oidc/graphql/fetch-app-secret-query.gql delete mode 100644 web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts delete mode 100644 web/api/helpers/oidc/graphql/fetch-oidc-app.gql delete mode 100644 web/api/helpers/oidc/graphql/insert-auth-code.generated.ts delete mode 100644 web/api/helpers/oidc/graphql/insert-auth-code.gql delete mode 100644 web/api/helpers/oidc/index.ts delete mode 100644 web/api/v1/jwks/graphql/get-jwks.generated.ts delete mode 100644 web/api/v1/jwks/graphql/get-jwks.graphql delete mode 100644 web/api/v1/jwks/index.ts delete mode 100644 web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts delete mode 100644 web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql delete mode 100644 web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts delete mode 100644 web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql delete mode 100644 web/api/v1/oidc/authorize/index.ts delete mode 100644 web/api/v1/oidc/introspect/index.ts delete mode 100644 web/api/v1/oidc/openid-configuration/index.ts delete mode 100644 web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts delete mode 100644 web/api/v1/oidc/token/graphql/delete-auth-code.graphql delete mode 100644 web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts delete mode 100644 web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql delete mode 100644 web/api/v1/oidc/token/index.ts delete mode 100644 web/api/v1/oidc/userinfo/index.ts delete mode 100644 web/api/v1/oidc/validate/index.ts delete mode 100644 web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/layout.tsx delete mode 100644 web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/page.tsx delete mode 100644 web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/proof-debugging/page.tsx delete mode 100644 web/app/api/%5Fdelete-jwks/route.ts delete mode 100644 web/app/api/hasura/reset-client-secret/route.ts delete mode 100644 web/app/api/v1/jwks/route.ts delete mode 100644 web/app/api/v1/oidc/authorize/route.ts delete mode 100644 web/app/api/v1/oidc/introspect/route.ts delete mode 100644 web/app/api/v1/oidc/openid-configuration/route.ts delete mode 100644 web/app/api/v1/oidc/token/route.ts delete mode 100644 web/app/api/v1/oidc/userinfo/route.ts delete mode 100644 web/app/api/v1/oidc/validate/route.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/ProofDebugging/page/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/error/error.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/layout/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Links/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/RedirectInput/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/index.tsx delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.generated.ts delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.graphql delete mode 100644 web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/index.tsx delete mode 100644 web/tests/api/__mocks__/jwks.mock.ts delete mode 100644 web/tests/api/delete-jwks.test.ts delete mode 100644 web/tests/api/v1/oidc/authorize.test.ts delete mode 100644 web/tests/api/v1/oidc/userinfo.test.ts delete mode 100644 web/tests/api/v1/oidc/validate.test.ts delete mode 100644 web/tests/integration/jwks.test.ts delete mode 100644 web/tests/integration/oidc/authorize.test.ts delete mode 100644 web/tests/integration/oidc/token.test.ts delete mode 100644 web/tests/unit/check-flow-type.test.ts diff --git a/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts b/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts index b92da7fb0..73e457410 100644 --- a/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts +++ b/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts @@ -47,27 +47,6 @@ describe("Auxiliary API Endpoints", () => { }); }); - describe("POST /api/_delete-jwks", () => { - it("Delete Expired JWKs With Authorization", async () => { - const response = await axios.post( - `${INTERNAL_API_URL}/api/_delete-jwks`, - {}, - { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }, - ); - - expect( - response.status, - `Delete expired JWKs request resolved with a wrong code:\n${JSON.stringify(response.data, null, 2)}`, - ).toBe(204); // This endpoint returns 204 No Content - expect(response.data).toBe(""); // Empty response body - }); - }); - describe("POST /api/_gen-external-nullifier", () => { let testTeamId: string | undefined; let testAppId: string | undefined; diff --git a/web/.env.example b/web/.env.example index 4f77ac9ee..db756de07 100644 --- a/web/.env.example +++ b/web/.env.example @@ -40,9 +40,6 @@ SENDGRID_API_KEY=your_sendgrid_api_key SENDGRID_EMAIL_FROM=your_email@example.com SENDGRID_TEAM_INVITE_TEMPLATE_ID=d-5cbc349e16344604813f74c1f8b0bbdc -# Sign in with World ID -SIGN_IN_WITH_WORLD_ID_APP_ID= - # Ironclad IRONCLAD_ACCESS_ID=your_ironclad_access_id IRONCLAD_GROUP_KEY=your_ironclad_group_key diff --git a/web/api/_delete-jwks/index.ts b/web/api/_delete-jwks/index.ts deleted file mode 100644 index db0cba148..000000000 --- a/web/api/_delete-jwks/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { _deleteExpiredJWKs } from "@/api/helpers/jwks"; -import { protectInternalEndpoint } from "@/api/helpers/utils"; -import { logger } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; - -/** - * Deletes expired JWKs - */ -export async function POST(request: NextRequest) { - const { isAuthenticated, errorResponse } = protectInternalEndpoint(request); - if (!isAuthenticated) { - return errorResponse; - } - logger.info("Starting deletion of expired jwks."); - - const response = await _deleteExpiredJWKs(); - - logger.info(`Deleted ${response} expired jwks.`); - - return new NextResponse(null, { status: 204 }); -} diff --git a/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts b/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts deleted file mode 100644 index e24bba16f..000000000 --- a/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type GetMembershipQueryVariables = Types.Exact<{ - team_id: Types.Scalars["String"]["input"]; - user_id: Types.Scalars["String"]["input"]; - app_id: Types.Scalars["String"]["input"]; -}>; - -export type GetMembershipQuery = { - __typename?: "query_root"; - team: Array<{ __typename?: "team"; id: string }>; -}; - -export const GetMembershipDocument = gql` - query GetMembership($team_id: String!, $user_id: String!, $app_id: String!) { - team( - where: { - id: { _eq: $team_id } - memberships: { - user_id: { _eq: $user_id } - role: { _in: [ADMIN, OWNER] } - } - apps: { id: { _eq: $app_id } } - } - ) { - id - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - GetMembership( - variables: GetMembershipQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(GetMembershipDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "GetMembership", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/hasura/reset-client-secret/graphql/get-membership.graphql b/web/api/hasura/reset-client-secret/graphql/get-membership.graphql deleted file mode 100644 index bd46dc78f..000000000 --- a/web/api/hasura/reset-client-secret/graphql/get-membership.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query GetMembership($team_id: String!, $user_id: String!, $app_id: String!) { - team( - where: { - id: { _eq: $team_id } - memberships: { user_id: { _eq: $user_id }, role: { _in: [ADMIN, OWNER] } } - apps: { id: { _eq: $app_id } } - } - ) { - id - } -} diff --git a/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts b/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts deleted file mode 100644 index c6bf48487..000000000 --- a/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type UpdateSecretMutationVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; - hashed_secret: Types.Scalars["String"]["input"]; -}>; - -export type UpdateSecretMutation = { - __typename?: "mutation_root"; - update_action?: { - __typename?: "action_mutation_response"; - affected_rows: number; - } | null; -}; - -export const UpdateSecretDocument = gql` - mutation UpdateSecret($app_id: String!, $hashed_secret: String!) { - update_action( - where: { app_id: { _eq: $app_id }, action: { _eq: "" } } - _set: { client_secret: $hashed_secret } - ) { - affected_rows - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - UpdateSecret( - variables: UpdateSecretMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - UpdateSecretDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "UpdateSecret", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/hasura/reset-client-secret/graphql/update-secret.graphql b/web/api/hasura/reset-client-secret/graphql/update-secret.graphql deleted file mode 100644 index 2e27c5c13..000000000 --- a/web/api/hasura/reset-client-secret/graphql/update-secret.graphql +++ /dev/null @@ -1,8 +0,0 @@ -mutation UpdateSecret($app_id: String!, $hashed_secret: String!) { - update_action( - where: { app_id: { _eq: $app_id }, action: { _eq: "" } } - _set: { client_secret: $hashed_secret } - ) { - affected_rows - } -} diff --git a/web/api/hasura/reset-client-secret/index.ts b/web/api/hasura/reset-client-secret/index.ts deleted file mode 100644 index 12caab52b..000000000 --- a/web/api/hasura/reset-client-secret/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { errorHasuraQuery } from "@/api/helpers/errors"; -import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; -import { - generateHashedSecret, - protectInternalEndpoint, -} from "@/api/helpers/utils"; -import { logger } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { getSdk as getMembershipSdk } from "./graphql/get-membership.generated"; -import { getSdk as updateSecretSDK } from "./graphql/update-secret.generated"; - -export const POST = async (req: NextRequest) => { - const { isAuthenticated, errorResponse } = protectInternalEndpoint(req); - if (!isAuthenticated) { - return errorResponse; - } - const body = await req.json(); - - if (body?.action.name !== "reset_client_secret") { - return errorHasuraQuery({ - req, - detail: "Invalid action.", - code: "invalid_action", - }); - } - - if (body.session_variables["x-hasura-role"] === "admin") { - logger.error("Admin not allowed to run _reset-client-client-secret"), - { role: body.session_variables["x-hasura-role"] }; - return errorHasuraQuery({ - req, - detail: "Admin is not allowed to run this query.", - code: "admin_not_allowed", - }); - } - - const userId = body.session_variables["x-hasura-user-id"]; - if (!userId) { - return errorHasuraQuery({ - req, - detail: "userId must be set.", - code: "required", - }); - } - - const team_id = body.input.team_id; - if (!team_id) { - return errorHasuraQuery({ - req, - detail: "team_id must be set.", - code: "required", - }); - } - - const app_id = body.input.app_id; - if (!app_id) { - return errorHasuraQuery({ - req, - detail: "`app_id` is a required input.", - code: "required", - team_id, - }); - } - - const client = await getAPIServiceGraphqlClient(); - - // ANCHOR: Make sure the user can perform this client reset - const { team: teamMembershipQuery } = await getMembershipSdk( - client, - ).GetMembership({ - user_id: userId, - team_id, - app_id, - }); - - if (!teamMembershipQuery || !teamMembershipQuery.length) { - return errorHasuraQuery({ - req, - detail: "Insufficient Permissions", - code: "insufficient_permissions", - team_id, - app_id, - }); - } - - const { secret: client_secret, hashed_secret } = generateHashedSecret(app_id); - const { update_action: updateResponse } = await updateSecretSDK( - client, - ).UpdateSecret({ - app_id: app_id, - hashed_secret, - }); - - if (!updateResponse?.affected_rows) { - return errorHasuraQuery({ - req, - detail: "Failed to reset the client secret.", - code: "update_failed", - team_id, - app_id, - }); - } - return NextResponse.json({ client_secret }); -}; diff --git a/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts b/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts deleted file mode 100644 index ca00f02b8..000000000 --- a/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type DeleteExpiredJwKsMutationVariables = Types.Exact<{ - expired_by?: Types.InputMaybe; -}>; - -export type DeleteExpiredJwKsMutation = { - __typename?: "mutation_root"; - delete_jwks?: { - __typename?: "jwks_mutation_response"; - returning: Array<{ - __typename?: "jwks"; - id: string; - kms_id?: string | null; - }>; - } | null; -}; - -export const DeleteExpiredJwKsDocument = gql` - mutation DeleteExpiredJWKs($expired_by: timestamptz = "") { - delete_jwks(where: { expires_at: { _lte: $expired_by } }) { - returning { - id - kms_id - } - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - DeleteExpiredJWKs( - variables?: DeleteExpiredJwKsMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - DeleteExpiredJwKsDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "DeleteExpiredJWKs", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/delete-expired-jwks.gql b/web/api/helpers/jwks/graphql/delete-expired-jwks.gql deleted file mode 100644 index e9a76de7c..000000000 --- a/web/api/helpers/jwks/graphql/delete-expired-jwks.gql +++ /dev/null @@ -1,8 +0,0 @@ -mutation DeleteExpiredJWKs($expired_by: timestamptz = "") { - delete_jwks(where: { expires_at: { _lte: $expired_by } }) { - returning { - id - kms_id - } - } -} diff --git a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts deleted file mode 100644 index 921bbbb37..000000000 --- a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type FetchActiveJwKsByExpirationQueryVariables = Types.Exact<{ - expires_at: Types.Scalars["timestamptz"]["input"]; -}>; - -export type FetchActiveJwKsByExpirationQuery = { - __typename?: "query_root"; - jwks: Array<{ - __typename?: "jwks"; - id: string; - kms_id?: string | null; - expires_at: string; - }>; -}; - -export const FetchActiveJwKsByExpirationDocument = gql` - query FetchActiveJWKsByExpiration($expires_at: timestamptz!) { - jwks( - where: { expires_at: { _gt: $expires_at } } - order_by: { expires_at: desc } - ) { - id - kms_id - expires_at - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - FetchActiveJWKsByExpiration( - variables: FetchActiveJwKsByExpirationQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - FetchActiveJwKsByExpirationDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "FetchActiveJWKsByExpiration", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql deleted file mode 100644 index 0df06c622..000000000 --- a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql +++ /dev/null @@ -1,10 +0,0 @@ -query FetchActiveJWKsByExpiration($expires_at: timestamptz!) { - jwks( - where: { expires_at: { _gt: $expires_at } } - order_by: { expires_at: desc } - ) { - id - kms_id - expires_at - } -} diff --git a/web/api/helpers/jwks/graphql/insert-jwk.generated.ts b/web/api/helpers/jwks/graphql/insert-jwk.generated.ts deleted file mode 100644 index 57904ae79..000000000 --- a/web/api/helpers/jwks/graphql/insert-jwk.generated.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type InsertJwkMutationVariables = Types.Exact<{ - expires_at: Types.Scalars["timestamptz"]["input"]; - public_jwk: Types.Scalars["jsonb"]["input"]; - kms_id: Types.Scalars["String"]["input"]; -}>; - -export type InsertJwkMutation = { - __typename?: "mutation_root"; - insert_jwks_one?: { - __typename?: "jwks"; - id: string; - kms_id?: string | null; - expires_at: string; - } | null; -}; - -export const InsertJwkDocument = gql` - mutation InsertJWK( - $expires_at: timestamptz! - $public_jwk: jsonb! - $kms_id: String! - ) { - insert_jwks_one( - object: { - expires_at: $expires_at - kms_id: $kms_id - public_jwk: $public_jwk - } - ) { - id - kms_id - expires_at - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - InsertJWK( - variables: InsertJwkMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(InsertJwkDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "InsertJWK", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/insert-jwk.gql b/web/api/helpers/jwks/graphql/insert-jwk.gql deleted file mode 100644 index 3edab5578..000000000 --- a/web/api/helpers/jwks/graphql/insert-jwk.gql +++ /dev/null @@ -1,17 +0,0 @@ -mutation InsertJWK( - $expires_at: timestamptz! - $public_jwk: jsonb! - $kms_id: String! -) { - insert_jwks_one( - object: { - expires_at: $expires_at - kms_id: $kms_id - public_jwk: $public_jwk - } - ) { - id - kms_id - expires_at - } -} diff --git a/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts b/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts deleted file mode 100644 index 5c3474f1b..000000000 --- a/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type RetrieveJwkQueryVariables = Types.Exact<{ - kid: Types.Scalars["String"]["input"]; -}>; - -export type RetrieveJwkQuery = { - __typename?: "query_root"; - jwks: Array<{ - __typename?: "jwks"; - id: string; - kms_id?: string | null; - public_jwk: any; - }>; -}; - -export const RetrieveJwkDocument = gql` - query RetrieveJWK($kid: String!) { - jwks(limit: 1, where: { id: { _eq: $kid } }) { - id - kms_id - public_jwk - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - RetrieveJWK( - variables: RetrieveJwkQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(RetrieveJwkDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "RetrieveJWK", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/retrieve-jwk.gql b/web/api/helpers/jwks/graphql/retrieve-jwk.gql deleted file mode 100644 index a2e4b7e4f..000000000 --- a/web/api/helpers/jwks/graphql/retrieve-jwk.gql +++ /dev/null @@ -1,7 +0,0 @@ -query RetrieveJWK($kid: String!) { - jwks(limit: 1, where: { id: { _eq: $kid } }) { - id - kms_id - public_jwk - } -} diff --git a/web/api/helpers/jwks/index.ts b/web/api/helpers/jwks/index.ts deleted file mode 100644 index 94a4ffc84..000000000 --- a/web/api/helpers/jwks/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -import "server-only"; - -import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; -import { - createKMSKey, - getKMSClient, - scheduleKeyDeletion, -} from "@/api/helpers/kms"; -import { JWK_TIME_TO_LIVE, JWK_TTL_USABLE } from "@/lib/constants"; -import { logger } from "@/lib/logger"; -import { createPublicKey } from "crypto"; -import dayjs from "dayjs"; - -import { - getSdk as getRetrieveJWKSdk, - RetrieveJwkQuery, -} from "./graphql/retrieve-jwk.generated"; - -import { - FetchActiveJwKsByExpirationQuery, - getSdk as fetchActiveJWKsByExpirationSdk, -} from "./graphql/fetch-active-jwks-by-expiration-query.generated"; - -import { - InsertJwkMutation, - getSdk as insertJWKSdk, -} from "./graphql/insert-jwk.generated"; - -import { - DeleteExpiredJwKsMutation, - getSdk as deleteExpiredJWKsSdk, -} from "./graphql/delete-expired-jwks.generated"; - -export type CreateJWKResult = { - keyId: string; - publicJwk: JsonWebKey; - createdAt: Date; -}; -/** - * Get the public JWK for a given kid - * @param kid - * @returns - */ -export const retrieveJWK = async (kid: string) => { - const client = await getAPIServiceGraphqlClient(); - - let jwks: RetrieveJwkQuery["jwks"] | null = null; - - try { - const data = await getRetrieveJWKSdk(client).RetrieveJWK({ - kid, - }); - - jwks = data.jwks; - } catch (error) { - logger.error("Error retrieving JWK.", { error }); - - throw error; - } - - if (!jwks?.length) { - throw new Error("JWK not found."); - } - - const { id, kms_id, public_jwk } = jwks[0]; - return { kid: id, kms_id, public_jwk }; -}; - -/** - * Generates an RS256 asymmetric key pair in JWK format - * @returns - */ -export const generateJWK = async (): Promise => { - const client = await getKMSClient(); - - if (client) { - const result = await createKMSKey(client, "RSA_2048"); - if (result?.keyId && result?.publicKey) { - const publicJwk = createPublicKey(result.publicKey).export({ - format: "jwk", - }); - - return { keyId: result.keyId, publicJwk, createdAt: result.createdAt }; - } else { - throw new Error("Unable to create KMS key."); - } - } else { - throw new Error("KMS client not found."); - } -}; - -/** - * Generate new JWK. Generates a new KMS key and stores the public key in the database. - * @param alg - * @returns - */ -export const createAndStoreJWK = async () => { - const key = await generateJWK(); - const expiresAt = dayjs(key.createdAt).add(JWK_TIME_TO_LIVE, "day"); - - const client = await getAPIServiceGraphqlClient(); - - let insertResult: InsertJwkMutation["insert_jwks_one"] | null = null; - - try { - const { insert_jwks_one } = await insertJWKSdk(client).InsertJWK({ - expires_at: expiresAt.toISOString(), - kms_id: key.keyId, - public_jwk: key.publicJwk, - }); - - insertResult = insert_jwks_one; - } catch (error) { - logger.error("Error inserting JWK.", { error }); - throw error; - } - - if (insertResult) { - return insertResult; - } - - logger.error("Unable to create new JWK.", { insertResult }); - throw new Error("Unable to create new JWK."); -}; - -/** - * Fetches an active JWK to sign requests, and otherwise rotates the key - * @param alg - * @returns - */ -export const fetchActiveJWK = async () => { - const apiClient = await getAPIServiceGraphqlClient(); - - let jwks: FetchActiveJwKsByExpirationQuery["jwks"] | null = null; - - try { - const data = await fetchActiveJWKsByExpirationSdk( - apiClient, - ).FetchActiveJWKsByExpiration({ - expires_at: new Date().toISOString(), - }); - - jwks = data.jwks; - } catch (error) { - logger.error("Error fetching active JWK.", { error }); - throw error; - } - - // JWK is still active - if (jwks?.length) { - const { id, kms_id, expires_at } = jwks[0]; - - // Only return JWK if it's not expiring in the next few days - const now = dayjs(); - const expires = dayjs(expires_at); - if (expires.diff(now, "day") > JWK_TTL_USABLE) { - return { kid: id, kms_id }; - } - } - - // JWK is expired or expiring soon, rotate the key - const jwk = await createAndStoreJWK(); - return { kid: jwk.id, kms_id: jwk.kms_id }; -}; - -/** - * Delete all expired JWKs from the database - * @returns - */ -export const _deleteExpiredJWKs = async () => { - const apiClient = await getAPIServiceGraphqlClient(); - - let deleteResult: DeleteExpiredJwKsMutation["delete_jwks"] | null = null; - - try { - const data = await deleteExpiredJWKsSdk(apiClient).DeleteExpiredJWKs({ - expired_by: new Date(Date.now() - 20 * 60 * 1000).toISOString(), // 20 minutes ago - }); - - deleteResult = data.delete_jwks; - } catch (error) { - logger.error("Error deleting expired JWKs.", { error }); - throw error; - } - - if (deleteResult?.returning) { - // Schedule each KMS key for deletion - const kmsClient = await getKMSClient(); - - if (kmsClient) { - for (const key of deleteResult.returning) { - if (!key.kms_id) { - logger.error("KMS ID not found for JWK.", { key }); - - continue; - } - - await scheduleKeyDeletion(kmsClient, key.kms_id); - } - } - - return deleteResult.returning.length; - } -}; diff --git a/web/api/helpers/jwts.ts b/web/api/helpers/jwts.ts index d8adf775e..57b6fdc41 100644 --- a/web/api/helpers/jwts.ts +++ b/web/api/helpers/jwts.ts @@ -2,15 +2,9 @@ import "server-only"; /** * Contains all backend utilities related to JWTs. - * * OIDC tokens * * Hasura authentication * * Developer Portal authentication */ -import { retrieveJWK } from "@/api/helpers/jwks"; -import { getKMSClient, signJWTWithKMSKey } from "@/api/helpers/kms"; -import { OIDCScopes } from "@/api/helpers/oidc"; -import { VerificationLevel } from "@worldcoin/idkit-core"; -import { randomUUID } from "crypto"; import dayjs from "dayjs"; import * as jose from "jose"; @@ -211,107 +205,3 @@ export const verifySignUpJWT = async (token: string) => { } return { sub }; }; - -// ANCHOR: -----------------OIDC JWTs-------------------------- - -const formatOIDCDateTime = (date: Date | dayjs.Dayjs): number => { - return dayjs(date).unix(); -}; - -interface IVerificationJWT { - kid: string; - kms_id: string; - nonce?: string; - nullifier_hash: string; - app_id: string; - verification_level: VerificationLevel; - scope: OIDCScopes[]; -} - -/** - * Generates a JWT that can be used to verify a proof (used for Sign in with World ID) - * @returns - */ -export const generateOIDCJWT = async ({ - app_id, - nonce, - nullifier_hash, - kid, - verification_level, - scope, -}: IVerificationJWT): Promise => { - const payload = { - iss: JWT_ISSUER, - sub: nullifier_hash, - jti: randomUUID(), - iat: formatOIDCDateTime(new Date()), - exp: formatOIDCDateTime(dayjs().add(1, "hour")), - aud: app_id, - scope: scope.join(" "), - // NOTE: DEPRECATED, will be removed in future versions - "https://id.worldcoin.org/beta": { - likely_human: - verification_level === VerificationLevel.Orb ? "strong" : "weak", - credential_type: verification_level, - warning: - "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", - }, - "https://id.worldcoin.org/v1": { - verification_level, - }, - } as Record; - - if (nonce) { - payload.nonce = nonce; - } - - if (scope.includes(OIDCScopes.Email)) { - payload.email = `${nullifier_hash}@id.worldcoin.org`; - } - - if (scope.includes(OIDCScopes.Profile)) { - payload.name = "World ID User"; - payload.given_name = "World ID"; - payload.family_name = "User"; - } - - // Sign the JWT with a KMS managed key - const client = await getKMSClient(); - const header = { - alg: "RS256", - typ: "JWT", - kid, - }; - - if (client) { - const token = await signJWTWithKMSKey(client, header, payload); - if (token) return token; - } - throw new Error("Failed to sign JWT from KMS."); -}; - -export const verifyOIDCJWT = async ( - token: string, -): Promise => { - const { kid } = jose.decodeProtectedHeader(token); - - if (!kid) { - throw new Error("JWT is invalid. Does not contain a `kid` claim."); - } - - const { public_jwk } = await retrieveJWK(kid); - - if (!public_jwk) { - throw new Error("Key for this JWT is invalid."); - } - - const { payload } = await jose.jwtVerify( - token, - await jose.importJWK(public_jwk, "RS256"), - { - issuer: JWT_ISSUER, - }, - ); - - return payload; -}; diff --git a/web/api/helpers/kms.ts b/web/api/helpers/kms.ts index 75a1ee7c6..7779c6c41 100644 --- a/web/api/helpers/kms.ts +++ b/web/api/helpers/kms.ts @@ -4,7 +4,6 @@ import "server-only"; * Contains all functions for interacting with Amazon KMS */ -import { retrieveJWK } from "@/api/helpers/jwks"; import { logger } from "@/lib/logger"; import { CreateKeyCommand, @@ -13,9 +12,7 @@ import { KMSClient, KeySpec, ScheduleKeyDeletionCommand, - SignCommand, } from "@aws-sdk/client-kms"; -import { base64url } from "jose"; export type CreateKeyResult = | { @@ -79,45 +76,6 @@ export const getKMSKeyStatus = async (client: KMSClient, keyId: string) => { } }; -export const signJWTWithKMSKey = async ( - client: KMSClient, - header: Record, - payload: Record, -) => { - const encodedHeader = base64url.encode(JSON.stringify(header)); - const encodedPayload = base64url.encode(JSON.stringify(payload)); - const encodedHeaderPayload = `${encodedHeader}.${encodedPayload}`; - - try { - const { kms_id } = await retrieveJWK(header.kid); // NOTE: JWK is already verified to be active at this point - - if (!kms_id) { - throw new Error("KMS ID not found."); - } - - const response = await client.send( - new SignCommand({ - KeyId: kms_id, - Message: new Uint8Array(Buffer.from(encodedHeaderPayload)), - MessageType: "RAW", - SigningAlgorithm: "RSASSA_PKCS1_V1_5_SHA_256", - }), - ); - - if (response?.Signature) { - // See: https://www.rfc-editor.org/rfc/rfc7515#appendix-C - const encodedSignature = base64url - .encode(response.Signature) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); - return `${encodedHeaderPayload}.${encodedSignature}`; - } - } catch (error) { - logger.error("Error signing JWT:", { error }); - } -}; - export const scheduleKeyDeletion = async (client: KMSClient, keyId: string) => { try { await client.send( diff --git a/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts b/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts deleted file mode 100644 index fc9a5e3ab..000000000 --- a/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type FetchAppSecretQueryVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; -}>; - -export type FetchAppSecretQuery = { - __typename?: "query_root"; - app: Array<{ - __typename?: "app"; - id: string; - actions: Array<{ __typename?: "action"; client_secret: string }>; - }>; -}; - -export const FetchAppSecretDocument = gql` - query FetchAppSecret($app_id: String!) { - app( - where: { - id: { _eq: $app_id } - status: { _eq: "active" } - is_archived: { _eq: false } - engine: { _eq: "cloud" } - } - ) { - id - actions(limit: 1, where: { action: { _eq: "" } }) { - client_secret - } - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - FetchAppSecret( - variables: FetchAppSecretQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - FetchAppSecretDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "FetchAppSecret", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql b/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql deleted file mode 100644 index abda88e8e..000000000 --- a/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql +++ /dev/null @@ -1,15 +0,0 @@ -query FetchAppSecret($app_id: String!) { - app( - where: { - id: { _eq: $app_id } - status: { _eq: "active" } - is_archived: { _eq: false } - engine: { _eq: "cloud" } - } - ) { - id - actions(limit: 1, where: { action: { _eq: "" } }) { - client_secret - } - } -} diff --git a/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts b/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts deleted file mode 100644 index 272cd073b..000000000 --- a/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type FetchOidcAppQueryVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; - redirect_uri: Types.Scalars["String"]["input"]; -}>; - -export type FetchOidcAppQuery = { - __typename?: "query_root"; - app: Array<{ - __typename?: "app"; - id: string; - is_staging: boolean; - actions: Array<{ - __typename?: "action"; - id: string; - external_nullifier: string; - status: string; - redirects: Array<{ __typename?: "redirect"; redirect_uri: string }>; - }>; - }>; -}; - -export const FetchOidcAppDocument = gql` - query FetchOIDCApp($app_id: String!, $redirect_uri: String!) { - app( - where: { - id: { _eq: $app_id } - status: { _eq: "active" } - is_archived: { _eq: false } - engine: { _eq: "cloud" } - } - ) { - id - is_staging - actions(where: { action: { _eq: "" } }) { - id - external_nullifier - status - redirects(where: { redirect_uri: { _eq: $redirect_uri } }) { - redirect_uri - } - } - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - FetchOIDCApp( - variables: FetchOidcAppQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(FetchOidcAppDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "FetchOIDCApp", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/fetch-oidc-app.gql b/web/api/helpers/oidc/graphql/fetch-oidc-app.gql deleted file mode 100644 index b82ab3ec6..000000000 --- a/web/api/helpers/oidc/graphql/fetch-oidc-app.gql +++ /dev/null @@ -1,21 +0,0 @@ -query FetchOIDCApp($app_id: String!, $redirect_uri: String!) { - app( - where: { - id: { _eq: $app_id } - status: { _eq: "active" } - is_archived: { _eq: false } - engine: { _eq: "cloud" } - } - ) { - id - is_staging - actions(where: { action: { _eq: "" } }) { - id - external_nullifier - status - redirects(where: { redirect_uri: { _eq: $redirect_uri } }) { - redirect_uri - } - } - } -} diff --git a/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts b/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts deleted file mode 100644 index 15f82d9a7..000000000 --- a/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type InsertAuthCodeMutationVariables = Types.Exact<{ - auth_code: Types.Scalars["String"]["input"]; - code_challenge?: Types.InputMaybe; - code_challenge_method?: Types.InputMaybe; - expires_at: Types.Scalars["timestamptz"]["input"]; - nullifier_hash: Types.Scalars["String"]["input"]; - app_id: Types.Scalars["String"]["input"]; - verification_level: Types.Scalars["String"]["input"]; - scope: Types.Scalars["jsonb"]["input"]; - nonce?: Types.InputMaybe; - redirect_uri?: Types.InputMaybe; -}>; - -export type InsertAuthCodeMutation = { - __typename?: "mutation_root"; - insert_auth_code_one?: { - __typename?: "auth_code"; - auth_code: string; - nonce?: string | null; - } | null; -}; - -export const InsertAuthCodeDocument = gql` - mutation InsertAuthCode( - $auth_code: String! - $code_challenge: String - $code_challenge_method: String - $expires_at: timestamptz! - $nullifier_hash: String! - $app_id: String! - $verification_level: String! - $scope: jsonb! - $nonce: String - $redirect_uri: String - ) { - insert_auth_code_one( - object: { - auth_code: $auth_code - code_challenge: $code_challenge - code_challenge_method: $code_challenge_method - expires_at: $expires_at - nullifier_hash: $nullifier_hash - app_id: $app_id - verification_level: $verification_level - scope: $scope - nonce: $nonce - redirect_uri: $redirect_uri - } - ) { - auth_code - nonce - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - InsertAuthCode( - variables: InsertAuthCodeMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - InsertAuthCodeDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "InsertAuthCode", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/insert-auth-code.gql b/web/api/helpers/oidc/graphql/insert-auth-code.gql deleted file mode 100644 index 2d1d5c890..000000000 --- a/web/api/helpers/oidc/graphql/insert-auth-code.gql +++ /dev/null @@ -1,30 +0,0 @@ -mutation InsertAuthCode( - $auth_code: String! - $code_challenge: String - $code_challenge_method: String - $expires_at: timestamptz! - $nullifier_hash: String! - $app_id: String! - $verification_level: String! - $scope: jsonb! - $nonce: String - $redirect_uri: String -) { - insert_auth_code_one( - object: { - auth_code: $auth_code - code_challenge: $code_challenge - code_challenge_method: $code_challenge_method - expires_at: $expires_at - nullifier_hash: $nullifier_hash - app_id: $app_id - verification_level: $verification_level - scope: $scope - nonce: $nonce - redirect_uri: $redirect_uri - } - ) { - auth_code - nonce - } -} diff --git a/web/api/helpers/oidc/index.ts b/web/api/helpers/oidc/index.ts deleted file mode 100644 index 6cc67a7e5..000000000 --- a/web/api/helpers/oidc/index.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { verifyHashedSecret } from "@/api/helpers/utils"; -import { logger } from "@/lib/logger"; -import { IInternalError, OIDCFlowType, OIDCResponseType } from "@/lib/types"; -import { VerificationLevel } from "@worldcoin/idkit-core"; -import crypto from "crypto"; -import "server-only"; - -import { - FetchAppSecretQuery, - getSdk as FetchAppSecretQuerySdk, -} from "./graphql/fetch-app-secret-query.generated"; - -import { - getSdk as FetchOIDCAppSdk, - FetchOidcAppQuery, -} from "./graphql/fetch-oidc-app.generated"; - -import { getAPIServiceGraphqlClient } from "../graphql"; -import { - InsertAuthCodeMutation, - getSdk as InsertAuthCodeSdk, -} from "./graphql/insert-auth-code.generated"; - -export const OIDCResponseTypeMapping = { - code: OIDCResponseType.Code, - id_token: OIDCResponseType.JWT, - token: OIDCResponseType.JWT, -}; - -export enum OIDCScopes { - OpenID = "openid", - Email = "email", - Profile = "profile", -} - -export enum OIDCErrorCodes { - InvalidRequest = "invalid_request", // RFC6749 OAuth 2.0 (4.1.2.1) - UnsupportedResponseType = "unsupported_response_type", // RFC6749 OAuth 2.0 (4.1.2.1) - InvalidScope = "invalid_scope", // RFC6749 OAuth 2.0 (4.1.2.1) - InvalidRedirectURI = "invalid_redirect_uri", // Custom -} - -interface OIDCApp { - id: FetchOidcAppQuery["app"][number]["id"]; - is_staging: FetchOidcAppQuery["app"][number]["is_staging"]; - external_nullifier: FetchOidcAppQuery["app"][number]["actions"][number]["external_nullifier"]; - action_id: FetchOidcAppQuery["app"][number]["actions"][number]["id"]; - registered_redirect_uri?: FetchOidcAppQuery["app"][number]["actions"][number]["redirects"][number]["redirect_uri"]; -} - -export const fetchOIDCApp = async ( - app_id: string, - redirect_uri: string, -): Promise<{ app?: OIDCApp; error?: IInternalError }> => { - const client = await getAPIServiceGraphqlClient(); - - let data: FetchOidcAppQuery | null = null; - - try { - data = await FetchOIDCAppSdk(client).FetchOIDCApp({ - app_id, - redirect_uri, - }); - } catch (error) { - logger.error("fetchOIDCApp - Failed to fetch OIDC app.", { error }); - - return { - error: { - code: "internal_server_error", - message: "Failed to fetch OIDC app.", - statusCode: 500, - }, - }; - } - - if (data.app.length === 0) { - return { - error: { - code: "app_not_found", - message: "App not found or not active.", - statusCode: 404, - }, - }; - } - - const app = data.app[0]; - - if (!app.actions?.length || app.actions[0].status === "inactive") { - return { - error: { - code: "sign_in_not_enabled", - message: "App does not have Sign in with World ID enabled.", - statusCode: 400, - }, - }; - } - - const external_nullifier = app.actions[0].external_nullifier; - const action_id = app.actions[0].id; - const registered_redirect_uri = app.actions[0].redirects[0]?.redirect_uri; - - const sanitizedApp = { ...app }; - const { actions, ...rest } = sanitizedApp; - - return { - app: { - ...rest, - action_id, - external_nullifier, - registered_redirect_uri, - }, - }; -}; - -export const generateOIDCCode = async ( - app_id: string, - nullifier_hash: string, - verification_level: VerificationLevel, - scope: OIDCScopes[], - redirect_uri: string, - code_challenge?: string, - code_challenge_method?: string, - nonce?: string | null, -): Promise => { - // Generate a random code - const auth_code = crypto.randomBytes(12).toString("hex"); - const client = await getAPIServiceGraphqlClient(); - let data: InsertAuthCodeMutation | null = null; - - try { - data = await InsertAuthCodeSdk(client).InsertAuthCode({ - app_id, - auth_code, - code_challenge, - code_challenge_method, - expires_at: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes - nullifier_hash, - verification_level, - scope, - nonce, - redirect_uri, - }); - } catch (error) { - logger.error("generateOIDCCode - Failed to generate auth code.", { error }); - throw error; - } - - if (data?.insert_auth_code_one?.auth_code !== auth_code) { - throw new Error("Failed to generate auth code."); - } - - return auth_code; -}; - -// TODO: Hash secrets as passwords (e.g. `PBKDF2`) instead of HMAC -export const authenticateOIDCEndpoint = async ( - auth_header: string, -): Promise => { - const authToken = auth_header.replace("Basic ", ""); - const [app_id, client_secret] = Buffer.from(authToken, "base64") - .toString() - .split(":"); - - // Fetch app - const client = await getAPIServiceGraphqlClient(); - - let data: FetchAppSecretQuery | null = null; - - try { - data = await FetchAppSecretQuerySdk(client).FetchAppSecret({ - app_id, - }); - } catch (error) { - logger.error("authenticateOIDCEndpoint - Failed to fetch app.", { error }); - return null; - } - - if (data.app.length === 0) { - logger.info("authenticateOIDCEndpoint - App not found or not active."); - return null; - } - - const hmac_secret = data.app[0]?.actions?.[0]?.client_secret; - - if (!hmac_secret) { - logger.info( - "authenticateOIDCEndpoint - App does not have Sign in with World ID enabled.", - ); - return null; - } - - // ANCHOR: Verify client secret - if (!verifyHashedSecret(app_id, client_secret, hmac_secret)) { - logger.warn("authenticateOIDCEndpoint - Invalid client secret."); - return null; - } - - return app_id; -}; - -export function checkFlowType(responseTypes: string[]) { - const includesAll = (requiredParams: string[]): boolean => { - return requiredParams.every((param) => responseTypes.includes(param)); - }; - - // NOTE: List of valid response types for the hybrid flow - // Source: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth:~:text=this%20value%20is%20code%C2%A0id_token%2C%20code%C2%A0token%2C%20or%20code%C2%A0id_token%C2%A0token. - if ( - includesAll([OIDCResponseType.Code, OIDCResponseType.IdToken]) || - includesAll([OIDCResponseType.Code, OIDCResponseType.Token]) || - includesAll([ - OIDCResponseType.Code, - OIDCResponseType.IdToken, - OIDCResponseType.Token, - ]) - ) { - return OIDCFlowType.Hybrid; - } - - // NOTE: List of valid response types for the code flow - // Source: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth:~:text=Authorization%20Code%20Flow%2C-,this%20value%20is%20code.,-client_id - if (includesAll([OIDCResponseType.Code])) { - return OIDCFlowType.AuthorizationCode; - } - - // NOTE: List of valid response types for the implicit flow - // Source: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth:~:text=this%20value%20is%20id_token%C2%A0token%20or%20id_token - if ( - includesAll([OIDCResponseType.IdToken]) || - includesAll([OIDCResponseType.IdToken, OIDCResponseType.Token]) - ) { - return OIDCFlowType.Implicit; - } - - if (includesAll([OIDCResponseType.Token])) { - return OIDCFlowType.Token; - } - - return null; -} diff --git a/web/api/v1/jwks/graphql/get-jwks.generated.ts b/web/api/v1/jwks/graphql/get-jwks.generated.ts deleted file mode 100644 index 25301f89a..000000000 --- a/web/api/v1/jwks/graphql/get-jwks.generated.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type JwkQueryQueryVariables = Types.Exact<{ [key: string]: never }>; - -export type JwkQueryQuery = { - __typename?: "query_root"; - jwks: Array<{ - __typename?: "jwks"; - id: string; - expires_at: string; - key: any; - }>; -}; - -export const JwkQueryDocument = gql` - query JWKQuery { - jwks { - id - expires_at - key: public_jwk - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - JWKQuery( - variables?: JwkQueryQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(JwkQueryDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "JWKQuery", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/v1/jwks/graphql/get-jwks.graphql b/web/api/v1/jwks/graphql/get-jwks.graphql deleted file mode 100644 index eb3d16db2..000000000 --- a/web/api/v1/jwks/graphql/get-jwks.graphql +++ /dev/null @@ -1,7 +0,0 @@ -query JWKQuery { - jwks { - id - expires_at - key: public_jwk - } -} diff --git a/web/api/v1/jwks/index.ts b/web/api/v1/jwks/index.ts deleted file mode 100644 index 96fc87413..000000000 --- a/web/api/v1/jwks/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; -import { corsHandler } from "@/api/helpers/utils"; -import { NextRequest, NextResponse } from "next/server"; -import { getSdk as getJWKsSdk } from "./graphql/get-jwks.generated"; - -const corsMethods = ["GET", "OPTIONS"]; - -/** - * Retrieves JWKs to verify proofs - * @param req - * @param res - */ -export async function GET(req: NextRequest) { - const client = await getAPIServiceGraphqlClient(); - const getJWKs = getJWKsSdk(client); - const response = await getJWKs.JWKQuery(); - - const keys = []; - for (const { id, key } of response.jwks) { - keys.push({ ...key, kid: id }); - } - - return corsHandler(NextResponse.json({ keys }), corsMethods); -} - -export async function OPTIONS(req: NextRequest) { - return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); -} diff --git a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts deleted file mode 100644 index 336bd1e05..000000000 --- a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type NullifierQueryVariables = Types.Exact<{ - nullifier_hash: Types.Scalars["String"]["input"]; -}>; - -export type NullifierQuery = { - __typename?: "query_root"; - nullifier: Array<{ __typename?: "nullifier"; id: string }>; -}; - -export const NullifierDocument = gql` - query Nullifier($nullifier_hash: String!) { - nullifier(where: { nullifier_hash: { _eq: $nullifier_hash } }) { - id - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - Nullifier( - variables: NullifierQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(NullifierDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "Nullifier", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql deleted file mode 100644 index 3ec6c2039..000000000 --- a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query Nullifier($nullifier_hash: String!) { - nullifier(where: { nullifier_hash: { _eq: $nullifier_hash } }) { - id - } -} diff --git a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts deleted file mode 100644 index 0becf53f3..000000000 --- a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type UpsertNullifierMutationVariables = Types.Exact<{ - object: Types.Nullifier_Insert_Input; - on_conflict: Types.Nullifier_On_Conflict; -}>; - -export type UpsertNullifierMutation = { - __typename?: "mutation_root"; - insert_nullifier_one?: { - __typename?: "nullifier"; - id: string; - nullifier_hash: string; - } | null; -}; - -export const UpsertNullifierDocument = gql` - mutation UpsertNullifier( - $object: nullifier_insert_input! - $on_conflict: nullifier_on_conflict! - ) { - insert_nullifier_one(object: $object, on_conflict: $on_conflict) { - id - nullifier_hash - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - UpsertNullifier( - variables: UpsertNullifierMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - UpsertNullifierDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "UpsertNullifier", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql deleted file mode 100644 index 92472526a..000000000 --- a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation UpsertNullifier( - $object: nullifier_insert_input! - $on_conflict: nullifier_on_conflict! -) { - insert_nullifier_one(object: $object, on_conflict: $on_conflict) { - id - nullifier_hash - } -} diff --git a/web/api/v1/oidc/authorize/index.ts b/web/api/v1/oidc/authorize/index.ts deleted file mode 100644 index 934db0c32..000000000 --- a/web/api/v1/oidc/authorize/index.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { errorResponse } from "@/api/helpers/errors"; -import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; -import { fetchActiveJWK } from "@/api/helpers/jwks"; -import { generateOIDCJWT } from "@/api/helpers/jwts"; -import { - OIDCErrorCodes, - OIDCResponseTypeMapping, - OIDCScopes, - checkFlowType, - fetchOIDCApp, - generateOIDCCode, -} from "@/api/helpers/oidc"; -import { corsHandler } from "@/api/helpers/utils"; -import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; -import { encodeNullifierForStorage, verifyProof } from "@/api/helpers/verify"; -import { Nullifier_Constraint } from "@/graphql/graphql"; -import { logger } from "@/lib/logger"; -import { OIDCFlowType, OIDCResponseType } from "@/lib/types"; -import { captureEvent } from "@/services/posthogClient"; -import { VerificationLevel } from "@worldcoin/idkit-core"; -import { hashToField } from "@worldcoin/idkit-core/hashing"; -import { createHash } from "crypto"; -import { toBeHex } from "ethers"; -import { NextRequest, NextResponse } from "next/server"; -import * as yup from "yup"; -import { getSdk as getNullifierSdk } from "./graphql/fetch-nullifier.generated"; -import { getSdk as getUpsertNullifierSdk } from "./graphql/upsert-nullifier.generated"; - -// NOTE: This endpoint should only be called from Sign in with World, params follow World ID conventions. Sign in with World handles OIDC requests. -const schema = yup - .object({ - proof: yup.string().strict().required("This attribute is required."), - nullifier_hash: yup - .string() - .strict() - .required("This attribute is required."), - merkle_root: yup.string().strict().required("This attribute is required."), - verification_level: yup - .string() - .oneOf(Object.values(VerificationLevel)) - .required("This attribute is required."), - app_id: yup.string().strict().required("This attribute is required."), - signal: yup // `signal` in the context of World ID; `nonce` in the context of OIDC - .string() - .ensure() - .when("response_type", { - is: (response_type: string) => - !["code", "code token"].includes(response_type), - then: (nonce) => - nonce.required( - "`nonce` required for all response types except `code` and `code token`.", - ), - }), // NOTE: nonce is required for all response types except `code` and `code token` - code_challenge: yup.string(), - code_challenge_method: yup.string(), - scope: yup - .string() - .strict() - .required("The openid scope is always required."), - response_type: yup - .string() - .strict() - .required("This attribute is required."), - redirect_uri: yup.string().strict().required("This attribute is required."), - }) - .noUnknown(); - -const corsMethods = ["POST", "OPTIONS"]; -/** - * Authenticates a "Sign in with World ID" user with a ZKP and issues a JWT or a code (authorization code flow) - * This endpoint is called by the Sign in with World ID page (or the app's own page if using IDKit [advanced]) - */ -export async function POST(req: NextRequest) { - const redis = global.RedisClient; - - if (!redis) { - return corsHandler( - errorResponse({ - statusCode: 500, - code: "internal_server_error", - detail: "Redis client not found", - attribute: "server", - req, - }), - corsMethods, - ); - } - - let app_id: string | undefined; - - try { - const body = await req.json(); - const { isValid, parsedParams, handleError } = await validateRequestSchema({ - schema, - value: body, - }); - - if (!isValid) { - return handleError(req); - } - - app_id = parsedParams.app_id; - - const { - proof, - nullifier_hash, - merkle_root, - signal, - verification_level, - response_type, - scope, - redirect_uri, - code_challenge, - code_challenge_method, - } = parsedParams; - - const response_types = decodeURIComponent( - (response_type as string | string[]).toString(), - ).split(" "); - - for (const response_type of response_types) { - if (!Object.keys(OIDCResponseTypeMapping).includes(response_type)) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: OIDCErrorCodes.UnsupportedResponseType, - detail: `Invalid response type: ${response_type}.`, - attribute: "response_type", - req, - app_id, - }), - corsMethods, - ); - } - } - - if (code_challenge && code_challenge_method !== "S256") { - return corsHandler( - errorResponse({ - statusCode: 400, - code: OIDCErrorCodes.InvalidRequest, - detail: `Invalid code_challenge_method: ${code_challenge_method}.`, - attribute: "code_challenge_method", - req, - app_id, - }), - corsMethods, - ); - } - - const scopes = decodeURIComponent( - (scope as string | string[])?.toString(), - ).split(" ") as OIDCScopes[]; - const sanitizedScopes: OIDCScopes[] = scopes.length - ? [ - ...new Set( - // NOTE: Invalid scopes are ignored per spec (3.1.2.1) - scopes.filter((scope) => Object.values(OIDCScopes).includes(scope)), - ), - ] - : []; - - if ( - !sanitizedScopes.length || - !sanitizedScopes.includes(OIDCScopes.OpenID) - ) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: OIDCErrorCodes.InvalidScope, - detail: `The ${OIDCScopes.OpenID} scope is always required.`, - attribute: "scope", - req, - app_id, - }), - corsMethods, - ); - } - - // ANCHOR: Check the app is valid and fetch information - const { app, error: fetchAppError } = await fetchOIDCApp( - app_id, - redirect_uri, - ); - if (!app || fetchAppError) { - return corsHandler( - errorResponse({ - statusCode: fetchAppError?.statusCode ?? 400, - code: fetchAppError?.code ?? "error", - detail: fetchAppError?.message ?? "Error fetching app.", - attribute: fetchAppError?.attribute ?? "app_id", - req, - app_id, - }), - corsMethods, - ); - } - - // ANCHOR: Verify redirect URI is valid - if (app.registered_redirect_uri !== redirect_uri) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: OIDCErrorCodes.InvalidRedirectURI, - detail: "Invalid redirect URI.", - attribute: "redirect_uri", - req, - app_id, - }), - corsMethods, - ); - } - - // Anchor: Check the proof hasn't been replayed - let hashedProof: string; - try { - hashedProof = createHash("sha256").update(proof).digest("hex"); - } catch (error) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: "invalid_proof", - detail: "Provided proof is invalid.", - attribute: "proof", - req, - app_id, - }), - corsMethods, - ); - } - const proofKey = `oidc:proof:${hashedProof}`; - const isProofReplayed = await redis.get(proofKey); - - if (isProofReplayed) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: "invalid_proof", - detail: "This proof has already been used. Please try again", - attribute: "proof", - req, - app_id, - }), - corsMethods, - ); - } - - // Set the proof before continuing with other operations - await redis.set(proofKey, "1", "EX", 5400); - - // For OIDC we should always hash the signal now. - let signalHash: string; - try { - signalHash = toBeHex(hashToField(signal).hash as bigint); - } catch (error) { - return corsHandler( - errorResponse({ - statusCode: 400, - code: "invalid_signal", - detail: "Provided signal is invalid.", - attribute: "signal", - req, - app_id, - }), - corsMethods, - ); - } - - // ANCHOR: Verify the zero-knowledge proof - const { error: verifyError } = await verifyProof( - { - proof, - nullifier_hash, - merkle_root, - signal_hash: signalHash, - external_nullifier: app.external_nullifier, - }, - { - is_staging: app.is_staging, - verification_level, - max_age: 3600, // require that root be less than 1 hour old - }, - ); - - if (verifyError) { - return corsHandler( - errorResponse({ - statusCode: verifyError.statusCode ?? 400, - code: verifyError.code ?? "invalid_proof", - detail: - verifyError.message ?? - "Verification request error. Please try again.", - attribute: verifyError.attribute, - req, - app_id, - }), - corsMethods, - ); - } - - // ANCHOR: Proof is valid, issue relevant codes - const response = {} as { code?: string; id_token?: string; token?: string }; - - if (response_types.includes(OIDCResponseType.Code)) { - const shouldStoreSignal = - checkFlowType(response_types) === OIDCFlowType.AuthorizationCode && - signal; - - response.code = await generateOIDCCode( - app.id, - nullifier_hash, - verification_level, - sanitizedScopes, - redirect_uri, - code_challenge, - code_challenge_method, - shouldStoreSignal ? signal : null, - ); - } - - let jwt: string | undefined; - for (const response_type of response_types) { - if ( - OIDCResponseTypeMapping[ - response_type as keyof typeof OIDCResponseTypeMapping - ] === OIDCResponseType.JWT - ) { - if (!jwt) { - const jwk = await fetchActiveJWK(); - - jwt = await generateOIDCJWT({ - app_id: app.id, - nullifier_hash, - verification_level, - nonce: signal, - scope: sanitizedScopes, - kid: jwk.kid, - kms_id: jwk.kms_id ?? "", - }); - } - - response[response_type as keyof typeof OIDCResponseTypeMapping] = jwt; - } - } - - const client = await getAPIServiceGraphqlClient(); - const nullifierSdk = getNullifierSdk(client); - const upsertNullifierSdk = getUpsertNullifierSdk(client); - - let hasNullifier: boolean = false; - - try { - const fetchNullifierResult = await nullifierSdk.Nullifier({ - nullifier_hash, - }); - - if (!fetchNullifierResult?.nullifier) { - logger.warn("Error fetching nullifier.", { - fetchNullifierResult: fetchNullifierResult ?? {}, - app_id, - }); - hasNullifier = false; - } - hasNullifier = Boolean(fetchNullifierResult.nullifier?.[0]?.id); - } catch (error) { - // Temp Fix to reduce on call alerts - logger.warn("Query error nullifier.", { - nullifier_hash, - errorMessage: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - app_id, - }); - } - - if (!hasNullifier) { - try { - const { insert_nullifier_one } = - await upsertNullifierSdk.UpsertNullifier({ - object: { - nullifier_hash, - action_id: app.action_id, - nullifier_hash_int: encodeNullifierForStorage(nullifier_hash), - }, - on_conflict: { - constraint: Nullifier_Constraint.NullifierPkey, - }, - }); - - if (!insert_nullifier_one) { - logger.error("Error inserting nullifier.", { - insert_nullifier_one: insert_nullifier_one ?? {}, - app_id, - }); - } - } catch (error) { - logger.error("Generic Error inserting nullifier", { - req, - error, - app_id, - }); - } - } - - await captureEvent({ - event: "world_id_sign_in_success", - distinctId: app.id, - properties: { - verification_level: verification_level, - }, - }); - - return corsHandler(NextResponse.json(response, { status: 200 }), [ - "POST", - "OPTIONS", - ]); - } catch (error) { - // Handle any unexpected errors - logger.error("Unexpected error in OIDC authorize", { - error, - app_id, - }); - - return corsHandler( - errorResponse({ - statusCode: 500, - code: "internal_server_error", - detail: "An unexpected error occurred", - attribute: "server", - req, - app_id, - }), - corsMethods, - ); - } -} - -export async function OPTIONS(req: NextRequest) { - return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); -} diff --git a/web/api/v1/oidc/introspect/index.ts b/web/api/v1/oidc/introspect/index.ts deleted file mode 100644 index 2f9393ab2..000000000 --- a/web/api/v1/oidc/introspect/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - errorResponse, - errorUnauthenticated, - errorValidation, -} from "@/api/helpers/errors"; -import { verifyOIDCJWT } from "@/api/helpers/jwts"; -import { authenticateOIDCEndpoint } from "@/api/helpers/oidc"; -import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; -import { NextRequest, NextResponse } from "next/server"; -import * as yup from "yup"; - -const schema = yup - .object({ - token: yup.string().strict().required("This attribute is required."), - }) - .noUnknown(); - -export async function POST(req: NextRequest) { - if (req.headers.get("content-type") !== "application/x-www-form-urlencoded") { - return errorValidation( - "invalid_content_type", - "Invalid content type. Only application/x-www-form-urlencoded is supported.", - null, - req, - ); - } - - const { isValid, parsedParams, handleError } = await validateRequestSchema({ - schema, - value: req.body, - }); - - if (!isValid) { - return handleError(req); - } - - const userToken = parsedParams.token; - - // ANCHOR: Authenticate the request comes from the app - const authHeader = req.headers.get("authorization"); - - if (!authHeader) { - return errorUnauthenticated( - "Please provide your app authentication credentials.", - req, - ); - } - - let app_id: string | null; - app_id = await authenticateOIDCEndpoint(authHeader); - - if (!app_id) { - return errorUnauthenticated("Invalid authentication credentials.", req); - } - - try { - const payload = await verifyOIDCJWT(userToken); - - return NextResponse.json({ - active: true, - client_id: app_id, - exp: payload.exp, - sub: payload.sub, - }); - } catch { - return errorResponse({ - statusCode: 401, - code: "invalid_token", - detail: "Token is invalid or expired.", - attribute: "token", - req, - app_id, - }); - } -} diff --git a/web/api/v1/oidc/openid-configuration/index.ts b/web/api/v1/oidc/openid-configuration/index.ts deleted file mode 100644 index a0c74103d..000000000 --- a/web/api/v1/oidc/openid-configuration/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { JWT_ISSUER } from "@/api/helpers/jwts"; -import { OIDCScopes } from "@/api/helpers/oidc"; -import { OIDC_BASE_URL } from "@/lib/constants"; -import { NextRequest, NextResponse } from "next/server"; - -/** - * Returns an OpenID Connect discovery document, according to spec - * https://openid.net/specs/openid-connect-discovery-1_0.html - * @param req - */ -export async function GET(req: NextRequest) { - return NextResponse.json({ - issuer: JWT_ISSUER, - jwks_uri: `${OIDC_BASE_URL}/jwks.json`, - token_endpoint: `${OIDC_BASE_URL}/token`, - code_challenge_methods_supported: ["S256"], - scopes_supported: Object.values(OIDCScopes), - id_token_signing_alg_values_supported: ["RSA"], - userinfo_endpoint: `${OIDC_BASE_URL}/userinfo`, - authorization_endpoint: `${OIDC_BASE_URL}/authorize`, - grant_types_supported: ["authorization_code", "implicit"], - service_documentation: "https://docs.world.org/world-id", - op_policy_uri: "https://developer.world.org/privacy-statement", - op_tos_uri: "https://developer.world.org/tos", - subject_types_supported: ["pairwise"], // subject is unique to each application, cannot be used across - response_modes_supported: ["query", "fragment", "form_post"], - response_types_supported: [ - "code", // Authorization code flow - "id_token", // Implicit flow - "id_token token", // Implicit flow - "code id_token", // Hybrid flow - ], - }); -} - -export async function OPTIONS(req: NextRequest) { - return NextResponse.json(null, { status: 204 }); -} diff --git a/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts b/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts deleted file mode 100644 index db825c384..000000000 --- a/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type DeleteAuthCodeMutationVariables = Types.Exact<{ - auth_code: Types.Scalars["String"]["input"]; - app_id: Types.Scalars["String"]["input"]; - now: Types.Scalars["timestamptz"]["input"]; -}>; - -export type DeleteAuthCodeMutation = { - __typename?: "mutation_root"; - delete_auth_code?: { - __typename?: "auth_code_mutation_response"; - affected_rows: number; - returning: Array<{ - __typename?: "auth_code"; - nullifier_hash: string; - verification_level: string; - scope?: any | null; - code_challenge?: string | null; - code_challenge_method?: string | null; - redirect_uri: string; - nonce?: string | null; - }>; - } | null; -}; - -export const DeleteAuthCodeDocument = gql` - mutation DeleteAuthCode( - $auth_code: String! - $app_id: String! - $now: timestamptz! - ) { - delete_auth_code( - where: { - app_id: { _eq: $app_id } - expires_at: { _gt: $now } - auth_code: { _eq: $auth_code } - } - ) { - returning { - nullifier_hash - verification_level - scope - code_challenge - code_challenge_method - redirect_uri - nonce - } - affected_rows - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - DeleteAuthCode( - variables: DeleteAuthCodeMutationVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - DeleteAuthCodeDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "DeleteAuthCode", - "mutation", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/token/graphql/delete-auth-code.graphql b/web/api/v1/oidc/token/graphql/delete-auth-code.graphql deleted file mode 100644 index 167833fb7..000000000 --- a/web/api/v1/oidc/token/graphql/delete-auth-code.graphql +++ /dev/null @@ -1,24 +0,0 @@ -mutation DeleteAuthCode( - $auth_code: String! - $app_id: String! - $now: timestamptz! -) { - delete_auth_code( - where: { - app_id: { _eq: $app_id } - expires_at: { _gt: $now } - auth_code: { _eq: $auth_code } - } - ) { - returning { - nullifier_hash - verification_level - scope - code_challenge - code_challenge_method - redirect_uri - nonce - } - affected_rows - } -} diff --git a/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts b/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts deleted file mode 100644 index 58394fb0b..000000000 --- a/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable import/no-relative-parent-imports -- auto generated file */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type FetchRedirectCountQueryQueryVariables = Types.Exact<{ - app_id?: Types.InputMaybe; -}>; - -export type FetchRedirectCountQueryQuery = { - __typename?: "query_root"; - action: Array<{ __typename?: "action"; redirect_count?: number | null }>; -}; - -export const FetchRedirectCountQueryDocument = gql` - query FetchRedirectCountQuery($app_id: String) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - redirect_count - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - FetchRedirectCountQuery( - variables?: FetchRedirectCountQueryQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request( - FetchRedirectCountQueryDocument, - variables, - { ...requestHeaders, ...wrappedRequestHeaders }, - ), - "FetchRedirectCountQuery", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql b/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql deleted file mode 100644 index ff511db8e..000000000 --- a/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query FetchRedirectCountQuery($app_id: String) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - redirect_count - } -} diff --git a/web/api/v1/oidc/token/index.ts b/web/api/v1/oidc/token/index.ts deleted file mode 100644 index d299b991f..000000000 --- a/web/api/v1/oidc/token/index.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { errorOIDCResponse } from "@/api/helpers/errors"; -import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; -import { fetchActiveJWK } from "@/api/helpers/jwks"; -import { generateOIDCJWT } from "@/api/helpers/jwts"; -import { authenticateOIDCEndpoint } from "@/api/helpers/oidc"; -import { corsHandler } from "@/api/helpers/utils"; -import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; -import { VerificationLevel } from "@worldcoin/idkit-core"; -import { createHash, timingSafeEqual } from "crypto"; -import { NextRequest, NextResponse } from "next/server"; -import * as yup from "yup"; -import { getSdk as getDeleteAuthCodeSdk } from "./graphql/delete-auth-code.generated"; -import { getSdk as getFetchRedirectCountSdk } from "./graphql/fetch-redirect-count.generated"; - -const corsMethods = ["POST", "OPTIONS"]; -const schema = yup - .object({ - grant_type: yup.string().default("authorization_code"), - code: yup.string().strict().required("This attribute is required."), - redirect_uri: yup.string().notRequired(), - client_id: yup.string().notRequired(), - client_secret: yup.string().notRequired(), - code_verifier: yup.string().notRequired(), - }) - .noUnknown(); - -export async function POST(req: NextRequest) { - if ( - !req.headers - .get("content-type") - ?.toLowerCase() - .startsWith("application/x-www-form-urlencoded") - ) { - const sanitizedContentType = req.headers - .get("content-type") - ?.replace(/\n|\r/g, ""); - console.warn("Invalid content type", sanitizedContentType); - return errorOIDCResponse( - 400, - "invalid_request", - "Invalid content type. Only application/x-www-form-urlencoded is supported.", - null, - req, - ); - } - - // ANCHOR: Authenticate the request - let authToken = req.headers.get("authorization"); - - const rawBody = await req.text(); - const params = new URLSearchParams(rawBody); - const body = Object.fromEntries(params.entries()); - const { isValid, parsedParams, handleError } = await validateRequestSchema({ - schema, - value: body, - }); - - if (!isValid) { - console.log("Invalid request", parsedParams); - return handleError(req); - } - - const { - grant_type, - code, - redirect_uri, - client_id, - client_secret, - code_verifier, - } = parsedParams; - - if (!authToken) { - // Attempt to get the credentials in the request body - if (client_id && client_secret) { - authToken = `Basic ${Buffer.from( - `${client_id}:${client_secret}`, - ).toString("base64")}`; - } - } - - if (!authToken) { - const response = errorOIDCResponse( - 401, - "unauthorized_client", - "Please provide your app authentication credentials.", - null, - req, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - - let app_id: string | null; - app_id = await authenticateOIDCEndpoint(authToken); - - if (!app_id) { - const response = errorOIDCResponse( - 401, - "unauthorized_client", - "Invalid authentication credentials", - null, - req, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - - const client = await getAPIServiceGraphqlClient(); - const deleteAuthCodeSdk = getDeleteAuthCodeSdk(client); - const fetchRedirectCountSdk = getFetchRedirectCountSdk(client); - const now = new Date().toISOString(); - const deleteAuthCodeResult = await deleteAuthCodeSdk.DeleteAuthCode({ - auth_code: code, - app_id, - now, - }); - - if ( - !deleteAuthCodeResult?.delete_auth_code || - !deleteAuthCodeResult.delete_auth_code.returning.length - ) { - const response = errorOIDCResponse( - 400, - "invalid_grant", - "Invalid authorization code.", - null, - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - - const authCode = deleteAuthCodeResult.delete_auth_code.returning[0]; - - if (!redirect_uri) { - const redirectCountResult = - await fetchRedirectCountSdk.FetchRedirectCountQuery({ - app_id, - }); - if ( - redirectCountResult?.action?.[0]?.redirect_count && - redirectCountResult?.action[0].redirect_count > 1 - ) { - const response = errorOIDCResponse( - 400, - "invalid_request", - "Missing redirect URI.", - "redirect_uri", - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - } else if (authCode.redirect_uri !== redirect_uri) { - const response = errorOIDCResponse( - 400, - "invalid_request", - "Invalid redirect URI.", - "redirect_uri", - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - - if (authCode.code_challenge) { - if (!code_verifier) { - const response = errorOIDCResponse( - 400, - "invalid_request", - "Missing code verifier.", - "code_verifier", - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - - // We only support S256 method - if (!verifyChallenge(authCode.code_challenge, code_verifier)) { - await deleteAuthCodeSdk.DeleteAuthCode({ - auth_code: code, - app_id, - now, - }); - - const response = errorOIDCResponse( - 400, - "invalid_request", - "Invalid code verifier.", - "code_verifier", - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - } else { - if (code_verifier) { - const response = errorOIDCResponse( - 400, - "invalid_request", - "Code verifier was not expected.", - "code_verifier", - req, - app_id, - ); - return code_verifier ? corsHandler(response, corsMethods) : response; - } - } - - const jwk = await fetchActiveJWK(); - const token = await generateOIDCJWT({ - app_id, - nullifier_hash: authCode.nullifier_hash, - verification_level: authCode.verification_level as VerificationLevel, - kid: jwk.kid, - kms_id: jwk.kms_id ?? "", - scope: authCode.scope, - nonce: authCode.nonce || undefined, - }); - - const response = NextResponse.json({ - access_token: token, - token_type: "Bearer", - expires_in: 3600, - scope: authCode.scope?.join(" ") || "", - id_token: token, - }); - - return code_verifier ? corsHandler(response, corsMethods) : response; -} - -const verifyChallenge = (challenge: string, verifier: string) => { - const hashedVerifier = createHash("sha256") - .update(verifier) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); - - return timingSafeEqual(Buffer.from(challenge), Buffer.from(hashedVerifier)); -}; - -export async function OPTIONS(req: NextRequest) { - return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); -} diff --git a/web/api/v1/oidc/userinfo/index.ts b/web/api/v1/oidc/userinfo/index.ts deleted file mode 100644 index b619d3529..000000000 --- a/web/api/v1/oidc/userinfo/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { errorResponse, errorUnauthenticated } from "@/api/helpers/errors"; -import { verifyOIDCJWT } from "@/api/helpers/jwts"; -import { corsHandler } from "@/api/helpers/utils"; -import { NextRequest, NextResponse } from "next/server"; - -const corsMethods = ["GET", "POST", "OPTIONS"]; - -/** - * Handles GET requests for the userinfo endpoint - */ -export async function GET(req: NextRequest) { - const authorization = req.headers.get("authorization"); - if (!authorization) { - return corsHandler( - errorUnauthenticated("Missing credentials.", req), - corsMethods, - ); - } - - const token = authorization.replace("Bearer ", ""); - - try { - const payload = await verifyOIDCJWT(token); - const response: Record = { - sub: payload.sub, - "https://id.worldcoin.org/beta": payload["https://id.worldcoin.org/beta"], - "https://id.worldcoin.org/v1": payload["https://id.worldcoin.org/v1"], - }; - const scopes = (payload.scope as string)?.toString().split(" "); - - if (scopes?.includes("email")) { - response.email = `${payload.sub}@id.worldcoin.org`; - } - - if (scopes?.includes("profile")) { - response.name = "World ID User"; - response.given_name = "World ID"; - response.family_name = "User"; - } - - return corsHandler(NextResponse.json(response), corsMethods); - } catch { - return corsHandler( - errorResponse({ - statusCode: 401, - code: "invalid_token", - detail: "Token is invalid or expired.", - attribute: "token", - req, - }), - corsMethods, - ); - } -} - -/** - * Handles POST requests for the userinfo endpoint - */ -export async function POST(req: NextRequest) { - const authorization = req.headers.get("authorization"); - if (!authorization) { - return corsHandler( - errorUnauthenticated("Missing credentials.", req), - corsMethods, - ); - } - - const token = authorization.replace("Bearer ", ""); - - try { - const payload = await verifyOIDCJWT(token); - const response: Record = { - sub: payload.sub, - "https://id.worldcoin.org/beta": payload["https://id.worldcoin.org/beta"], - "https://id.worldcoin.org/v1": payload["https://id.worldcoin.org/v1"], - }; - const scopes = (payload.scope as string)?.toString().split(" "); - - if (scopes?.includes("email")) { - response.email = `${payload.sub}@id.worldcoin.org`; - } - - if (scopes?.includes("profile")) { - response.name = "World ID User"; - response.given_name = "World ID"; - response.family_name = "User"; - } - - return corsHandler(NextResponse.json(response), corsMethods); - } catch { - return corsHandler( - errorResponse({ - statusCode: 401, - code: "invalid_token", - detail: "Token is invalid or expired.", - attribute: "token", - req, - }), - corsMethods, - ); - } -} - -/** - * Handles OPTIONS requests - */ -export async function OPTIONS(req: NextRequest) { - return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); -} diff --git a/web/api/v1/oidc/validate/index.ts b/web/api/v1/oidc/validate/index.ts deleted file mode 100644 index 033caeb23..000000000 --- a/web/api/v1/oidc/validate/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { errorResponse } from "@/api/helpers/errors"; -import { fetchOIDCApp } from "@/api/helpers/oidc"; -import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; -import { validateUrl } from "@/lib/utils"; -import { NextRequest, NextResponse } from "next/server"; -import * as yup from "yup"; - -const schema = yup - .object({ - app_id: yup.string().strict().required("This attribute is required."), - redirect_uri: yup.string().strict().required("This attribute is required."), - }) - .noUnknown(); - -/** - * Prevalidates app_id & redirect_uri is valid for Sign in with World ID for early user feedback - */ -export async function POST(req: NextRequest) { - const body = await req.json(); - const { isValid, parsedParams, handleError } = await validateRequestSchema({ - schema, - value: body, - }); - - if (!isValid) { - return handleError(req); - } - - const { app_id, redirect_uri } = parsedParams; - - const { app, error: fetchAppError } = await fetchOIDCApp( - app_id, - redirect_uri, - ); - if (!app || fetchAppError) { - return errorResponse({ - statusCode: fetchAppError?.statusCode ?? 400, - code: fetchAppError?.code ?? "error", - detail: fetchAppError?.message ?? "Error fetching app.", - attribute: fetchAppError?.attribute ?? "app_id", - req, - app_id, - }); - } - - if (!validateUrl(redirect_uri, app.is_staging)) { - return errorResponse({ - statusCode: 400, - code: "invalid_redirect_uri", - detail: "Invalid redirect_uri provided.", - attribute: "redirect_uri", - req, - app_id, - }); - } - - if (app.registered_redirect_uri !== redirect_uri) { - return errorResponse({ - statusCode: 400, - code: "invalid_redirect_uri", - detail: "Invalid redirect_uri provided.", - attribute: "redirect_uri", - req, - app_id, - }); - } - - return NextResponse.json({ app_id, redirect_uri }); -} diff --git a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/layout.tsx b/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/layout.tsx deleted file mode 100644 index a574768d6..000000000 --- a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/layout.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { SignInWithWorldIdLayout } from "@/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/layout"; -export default SignInWithWorldIdLayout; diff --git a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/page.tsx b/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/page.tsx deleted file mode 100644 index 722e42d24..000000000 --- a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { generateMetaTitle } from "@/lib/genarate-title"; -import { SignInWithWorldIdPage } from "@/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page"; -import { Metadata } from "next"; - -export const metadata: Metadata = { - title: generateMetaTitle({ left: "Sign in with World ID" }), -}; - -export default SignInWithWorldIdPage; diff --git a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/proof-debugging/page.tsx b/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/proof-debugging/page.tsx deleted file mode 100644 index f803854f7..000000000 --- a/web/app/(portal)/teams/[teamId]/apps/[appId]/sign-in-with-world-id/proof-debugging/page.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { SignInWithWorldIdProofDebuggingPage } from "@/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/ProofDebugging/page"; -export default SignInWithWorldIdProofDebuggingPage; diff --git a/web/app/api/%5Fdelete-jwks/route.ts b/web/app/api/%5Fdelete-jwks/route.ts deleted file mode 100644 index 57964ae74..000000000 --- a/web/app/api/%5Fdelete-jwks/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from "@/api/_delete-jwks"; diff --git a/web/app/api/hasura/reset-client-secret/route.ts b/web/app/api/hasura/reset-client-secret/route.ts deleted file mode 100644 index d2cf1a07f..000000000 --- a/web/app/api/hasura/reset-client-secret/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from "@/api/hasura/reset-client-secret"; diff --git a/web/app/api/v1/jwks/route.ts b/web/app/api/v1/jwks/route.ts deleted file mode 100644 index 243e17c1b..000000000 --- a/web/app/api/v1/jwks/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { GET, OPTIONS } from "@/api/v1/jwks"; diff --git a/web/app/api/v1/oidc/authorize/route.ts b/web/app/api/v1/oidc/authorize/route.ts deleted file mode 100644 index 81d47643c..000000000 --- a/web/app/api/v1/oidc/authorize/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { OPTIONS, POST } from "@/api/v1/oidc/authorize"; diff --git a/web/app/api/v1/oidc/introspect/route.ts b/web/app/api/v1/oidc/introspect/route.ts deleted file mode 100644 index d53cf057c..000000000 --- a/web/app/api/v1/oidc/introspect/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from "@/api/v1/oidc/introspect"; diff --git a/web/app/api/v1/oidc/openid-configuration/route.ts b/web/app/api/v1/oidc/openid-configuration/route.ts deleted file mode 100644 index 3f619371b..000000000 --- a/web/app/api/v1/oidc/openid-configuration/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { GET, OPTIONS } from "@/api/v1/oidc/openid-configuration"; diff --git a/web/app/api/v1/oidc/token/route.ts b/web/app/api/v1/oidc/token/route.ts deleted file mode 100644 index 5dfb64417..000000000 --- a/web/app/api/v1/oidc/token/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { OPTIONS, POST } from "@/api/v1/oidc/token"; diff --git a/web/app/api/v1/oidc/userinfo/route.ts b/web/app/api/v1/oidc/userinfo/route.ts deleted file mode 100644 index af5c26546..000000000 --- a/web/app/api/v1/oidc/userinfo/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { GET, OPTIONS, POST } from "@/api/v1/oidc/userinfo"; diff --git a/web/app/api/v1/oidc/validate/route.ts b/web/app/api/v1/oidc/validate/route.ts deleted file mode 100644 index eae4d7f0c..000000000 --- a/web/app/api/v1/oidc/validate/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from "@/api/v1/oidc/validate"; diff --git a/web/lib/constants.ts b/web/lib/constants.ts index 1bbba29e0..d048d3f3b 100644 --- a/web/lib/constants.ts +++ b/web/lib/constants.ts @@ -34,15 +34,9 @@ export const SECURE_DOCUMENT_SEQUENCER_STAGING = export const FACE_SEQUENCER_STAGING = "https://signup-face.stage-crypto.worldcoin.dev"; -// ANCHOR: OIDC Base URL -export const OIDC_BASE_URL = "https://id.worldcoin.org"; export const DOCS_URL = "https://docs.world.org"; export const DOCS_CLOUD_URL = "https://docs.world.org/id/cloud"; -// ANCHOR: JWKs -export const JWK_TIME_TO_LIVE = 30; // days; duration before a JWK is rotated -export const JWK_TTL_USABLE = 7; // days; duration before a JWK is rotated - export const SIMULATOR_URL = "https://simulator.worldcoin.org"; export const TELEGRAM_DEVELOPERS_GROUP_URL = "https://t.me/worldcoindevelopers"; export const TELEGRAM_MATEO_URL = "https://t.me/MateoSauton"; diff --git a/web/lib/urls.ts b/web/lib/urls.ts index c22d38080..8e07b55b7 100644 --- a/web/lib/urls.ts +++ b/web/lib/urls.ts @@ -89,9 +89,6 @@ export const urls = { createTeam: (): "/create-team" => "/create-team", - signInWorldId: (params: { team_id: string; app_id?: string }): string => - `/teams/${params.team_id}/apps/${params.app_id}/sign-in-with-world-id`, - signUp: (): "/signup" => "/signup", login: (params?: { invite_id: string }): string => diff --git a/web/next.config.mjs b/web/next.config.mjs index 0e9cd9ff1..72fad492a 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -86,10 +86,6 @@ const nextConfig = { async rewrites() { return [ - { - source: "/.well-known/openid-configuration", - destination: "/api/v1/oidc/openid-configuration", - }, { source: "/ingest/:path*", destination: "https://app.posthog.com/:path*", diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/ProofDebugging/page/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/ProofDebugging/page/index.tsx deleted file mode 100644 index 20fa1a7d6..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/ProofDebugging/page/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const SignInWithWorldIdProofDebuggingPage = () => { - return ( -
-

SignInWithWorldIdProofDebugging

-
- ); -}; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/error/error.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/error/error.tsx deleted file mode 100644 index b885107d3..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/error/error.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; // Error components must be Client Components - -import { useEffect } from "react"; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - console.error("Sign in With World ID Error: ", error); - }, [error]); - - return ( -
-

Something went wrong!

- -
- ); -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/layout/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/layout/index.tsx deleted file mode 100644 index 3b66d54da..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/layout/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { SizingWrapper } from "@/components/SizingWrapper"; -import { ReactNode } from "react"; - -export const SignInWithWorldIdLayout = (props: { children: ReactNode }) => { - return ( -
- {props.children} -
- ); -}; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Links/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Links/index.tsx deleted file mode 100644 index 6db77024b..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Links/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { DecoratedButton } from "@/components/DecoratedButton"; -import { Input } from "@/components/Input"; -import { TYPOGRAPHY, Typography } from "@/components/Typography"; -import { yupResolver } from "@hookform/resolvers/yup"; -import clsx from "clsx"; -import { memo, useCallback } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "react-toastify"; -import * as yup from "yup"; -import { SignInActionQuery } from "../../graphql/server/fetch-signin.generated"; -import { useUpdateSignInActionMutation } from "../graphql/client/update-sign-in-action.generated"; - -const schema = yup - .object({ - privacy_policy_uri: yup.string().url("Must be a valid URL").optional(), - terms_uri: yup.string().url("Must be a valid URL").optional(), - }) - .noUnknown(); - -type ClientInformation = yup.InferType; - -// This component will not be rendered if signInAction is not defined -export const LinksForm = memo(function LinksForm(props: { - teamId: string; - signInAction: SignInActionQuery["action"][0]; - canEdit: boolean; -}) { - const { teamId, signInAction, canEdit } = props; - const [updateSignInActionMutation] = useUpdateSignInActionMutation(); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: yupResolver(schema), - shouldFocusError: false, - defaultValues: { - privacy_policy_uri: signInAction?.privacy_policy_uri ?? "", - terms_uri: signInAction?.terms_uri ?? "", - }, - }); - - const submit = useCallback( - async (data: ClientInformation) => { - try { - if (!signInAction) return; // This should never happen - await updateSignInActionMutation({ - variables: { - id: signInAction?.id, - input: { - privacy_policy_uri: data.privacy_policy_uri, - terms_uri: data.terms_uri, - }, - }, - }); - toast.success("Links saved!"); - } catch (error) { - console.error("Update Sign in Links Error: ", error); - toast.error("Error updating action"); - } - }, - [signInAction, updateSignInActionMutation], - ); - - return ( -
-
- - Legal links - - - Links to where your Privacy Policy and Terms of Use are posted - -
- - - - - Save Changes - -
- ); -}); diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/RedirectInput/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/RedirectInput/index.tsx deleted file mode 100644 index 786617c47..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/RedirectInput/index.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; -import { validateUrl } from "@/lib/utils"; -import { yupResolver } from "@hookform/resolvers/yup"; -import clsx from "clsx"; -import { InputHTMLAttributes, memo, useMemo } from "react"; -import { useForm } from "react-hook-form"; -import { twMerge } from "tailwind-merge"; -import * as yup from "yup"; - -interface InputInterface extends InputHTMLAttributes { - required?: boolean; - currentValue?: string; - placeholder?: string; - helperText?: string; - addOnRight?: React.ReactElement; - className?: string; - isStaging: boolean; - handleChange: (value: string) => void; -} - -export const RedirectInput = memo(function Input(props: InputInterface) { - const { - required, - currentValue, - helperText, - placeholder, - className, - addOnRight, - disabled, - isStaging, - handleChange, - } = props; - - const schema = useMemo( - () => - yup - .object({ - url: yup - .string() - .required("A valid url is required") - .test("is-url", "Must be a valid URL", (value) => { - return value != null ? validateUrl(value, isStaging) : true; - }), - }) - .noUnknown(), - [isStaging], - ); - - type UrlFormValues = yup.InferType; - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: yupResolver(schema), - mode: "onChange", - shouldFocusError: false, - defaultValues: { - url: currentValue ?? "", - }, - }); - - const parentClassNames = clsx( - "rounded-lg border-[1px] bg-grey-0 px-2 text-sm text-grey-700", - { - "border-grey-200 focus-within:border-blue-500 focus-within:hover:border-blue-500 hover:border-grey-700 ": - !errors.url && !disabled, - "border-system-error-500 text-system-error-500 focus-within:border-system-error-500": - errors.url && !disabled, - }, - { - "hover:text-grey-700": !disabled, - "bg-grey-50 text-grey-300 border-grey-200": disabled, - }, - ); - - const inputClassNames = clsx( - "peer size-full bg-transparent p-2 focus:outline-none focus:ring-0", - { - "placeholder:text-grey-400": !errors.url, - "group-hover:placeholder:text-grey-700 group-hover:focus:placeholder:text-grey-400 ": - !disabled, - }, - ); - - const handleSave = handleSubmit((data) => { - handleChange(data.url); - }); - - return ( -
-
- -
{addOnRight && addOnRight}
-
-
- {helperText && ( -

{helperText}

- )} - {errors?.url?.message && ( -

- {errors.url.message} -

- )} -
-
- ); -}); diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.generated.ts deleted file mode 100644 index d48d5e27b..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.generated.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type DeleteRedirectMutationVariables = Types.Exact<{ - id: Types.Scalars["String"]["input"]; -}>; - -export type DeleteRedirectMutation = { - __typename?: "mutation_root"; - delete_redirect_by_pk?: { __typename?: "redirect"; id: string } | null; -}; - -export const DeleteRedirectDocument = gql` - mutation DeleteRedirect($id: String!) { - delete_redirect_by_pk(id: $id) { - id - } - } -`; -export type DeleteRedirectMutationFn = Apollo.MutationFunction< - DeleteRedirectMutation, - DeleteRedirectMutationVariables ->; - -/** - * __useDeleteRedirectMutation__ - * - * To run a mutation, you first call `useDeleteRedirectMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useDeleteRedirectMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [deleteRedirectMutation, { data, loading, error }] = useDeleteRedirectMutation({ - * variables: { - * id: // value for 'id' - * }, - * }); - */ -export function useDeleteRedirectMutation( - baseOptions?: Apollo.MutationHookOptions< - DeleteRedirectMutation, - DeleteRedirectMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation< - DeleteRedirectMutation, - DeleteRedirectMutationVariables - >(DeleteRedirectDocument, options); -} -export type DeleteRedirectMutationHookResult = ReturnType< - typeof useDeleteRedirectMutation ->; -export type DeleteRedirectMutationResult = - Apollo.MutationResult; -export type DeleteRedirectMutationOptions = Apollo.BaseMutationOptions< - DeleteRedirectMutation, - DeleteRedirectMutationVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.graphql deleted file mode 100644 index 99a6667a5..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/delete-redirect.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation DeleteRedirect($id: String!) { - delete_redirect_by_pk(id: $id) { - id - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.generated.ts deleted file mode 100644 index aa2b3dcb9..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.generated.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type RedirectsQueryVariables = Types.Exact<{ - action_id: Types.Scalars["String"]["input"]; -}>; - -export type RedirectsQuery = { - __typename?: "query_root"; - redirect: Array<{ - __typename?: "redirect"; - id: string; - action_id: string; - redirect_uri: string; - created_at: string; - updated_at: string; - }>; -}; - -export const RedirectsDocument = gql` - query Redirects($action_id: String!) { - redirect( - where: { action_id: { _eq: $action_id } } - order_by: { created_at: asc } - ) { - id - action_id - redirect_uri - created_at - updated_at - } - } -`; - -/** - * __useRedirectsQuery__ - * - * To run a query within a React component, call `useRedirectsQuery` and pass it any options that fit your needs. - * When your component renders, `useRedirectsQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useRedirectsQuery({ - * variables: { - * action_id: // value for 'action_id' - * }, - * }); - */ -export function useRedirectsQuery( - baseOptions: Apollo.QueryHookOptions< - RedirectsQuery, - RedirectsQueryVariables - > & - ( - | { variables: RedirectsQueryVariables; skip?: boolean } - | { skip: boolean } - ), -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useQuery( - RedirectsDocument, - options, - ); -} -export function useRedirectsLazyQuery( - baseOptions?: Apollo.LazyQueryHookOptions< - RedirectsQuery, - RedirectsQueryVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useLazyQuery( - RedirectsDocument, - options, - ); -} -export function useRedirectsSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions, -) { - const options = - baseOptions === Apollo.skipToken - ? baseOptions - : { ...defaultOptions, ...baseOptions }; - return Apollo.useSuspenseQuery( - RedirectsDocument, - options, - ); -} -export type RedirectsQueryHookResult = ReturnType; -export type RedirectsLazyQueryHookResult = ReturnType< - typeof useRedirectsLazyQuery ->; -export type RedirectsSuspenseQueryHookResult = ReturnType< - typeof useRedirectsSuspenseQuery ->; -export type RedirectsQueryResult = Apollo.QueryResult< - RedirectsQuery, - RedirectsQueryVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.graphql deleted file mode 100644 index 51919e624..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/fetch-redirect.graphql +++ /dev/null @@ -1,12 +0,0 @@ -query Redirects($action_id: String!) { - redirect( - where: { action_id: { _eq: $action_id } } - order_by: { created_at: asc } - ) { - id - action_id - redirect_uri - created_at - updated_at - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.generated.ts deleted file mode 100644 index 221158af9..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.generated.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type InsertRedirectMutationVariables = Types.Exact<{ - action_id: Types.Scalars["String"]["input"]; - uri: Types.Scalars["String"]["input"]; -}>; - -export type InsertRedirectMutation = { - __typename?: "mutation_root"; - insert_redirect_one?: { - __typename?: "redirect"; - id: string; - action_id: string; - redirect_uri: string; - created_at: string; - updated_at: string; - } | null; -}; - -export const InsertRedirectDocument = gql` - mutation InsertRedirect($action_id: String!, $uri: String!) { - insert_redirect_one(object: { action_id: $action_id, redirect_uri: $uri }) { - id - action_id - redirect_uri - created_at - updated_at - } - } -`; -export type InsertRedirectMutationFn = Apollo.MutationFunction< - InsertRedirectMutation, - InsertRedirectMutationVariables ->; - -/** - * __useInsertRedirectMutation__ - * - * To run a mutation, you first call `useInsertRedirectMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useInsertRedirectMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [insertRedirectMutation, { data, loading, error }] = useInsertRedirectMutation({ - * variables: { - * action_id: // value for 'action_id' - * uri: // value for 'uri' - * }, - * }); - */ -export function useInsertRedirectMutation( - baseOptions?: Apollo.MutationHookOptions< - InsertRedirectMutation, - InsertRedirectMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation< - InsertRedirectMutation, - InsertRedirectMutationVariables - >(InsertRedirectDocument, options); -} -export type InsertRedirectMutationHookResult = ReturnType< - typeof useInsertRedirectMutation ->; -export type InsertRedirectMutationResult = - Apollo.MutationResult; -export type InsertRedirectMutationOptions = Apollo.BaseMutationOptions< - InsertRedirectMutation, - InsertRedirectMutationVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.graphql deleted file mode 100644 index 699b20391..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/insert-redirect.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation InsertRedirect($action_id: String!, $uri: String!) { - insert_redirect_one(object: { action_id: $action_id, redirect_uri: $uri }) { - id - action_id - redirect_uri - created_at - updated_at - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.generated.ts deleted file mode 100644 index 119c3b0a8..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.generated.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type UpdateRedirectMutationVariables = Types.Exact<{ - id: Types.Scalars["String"]["input"]; - uri: Types.Scalars["String"]["input"]; -}>; - -export type UpdateRedirectMutation = { - __typename?: "mutation_root"; - update_redirect_by_pk?: { - __typename?: "redirect"; - id: string; - action_id: string; - redirect_uri: string; - created_at: string; - updated_at: string; - } | null; -}; - -export const UpdateRedirectDocument = gql` - mutation UpdateRedirect($id: String!, $uri: String!) { - update_redirect_by_pk( - pk_columns: { id: $id } - _set: { redirect_uri: $uri } - ) { - id - action_id - redirect_uri - created_at - updated_at - } - } -`; -export type UpdateRedirectMutationFn = Apollo.MutationFunction< - UpdateRedirectMutation, - UpdateRedirectMutationVariables ->; - -/** - * __useUpdateRedirectMutation__ - * - * To run a mutation, you first call `useUpdateRedirectMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useUpdateRedirectMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [updateRedirectMutation, { data, loading, error }] = useUpdateRedirectMutation({ - * variables: { - * id: // value for 'id' - * uri: // value for 'uri' - * }, - * }); - */ -export function useUpdateRedirectMutation( - baseOptions?: Apollo.MutationHookOptions< - UpdateRedirectMutation, - UpdateRedirectMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation< - UpdateRedirectMutation, - UpdateRedirectMutationVariables - >(UpdateRedirectDocument, options); -} -export type UpdateRedirectMutationHookResult = ReturnType< - typeof useUpdateRedirectMutation ->; -export type UpdateRedirectMutationResult = - Apollo.MutationResult; -export type UpdateRedirectMutationOptions = Apollo.BaseMutationOptions< - UpdateRedirectMutation, - UpdateRedirectMutationVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.graphql deleted file mode 100644 index 50cc95ec5..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/graphql/client/update-redirect.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation UpdateRedirect($id: String!, $uri: String!) { - update_redirect_by_pk(pk_columns: { id: $id }, _set: { redirect_uri: $uri }) { - id - action_id - redirect_uri - created_at - updated_at - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/index.tsx deleted file mode 100644 index cc0b38f1e..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/Redirects/index.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client"; - -import { Button } from "@/components/Button"; -import { DecoratedButton } from "@/components/DecoratedButton"; -import { CloseIcon } from "@/components/Icons/CloseIcon"; -import { TYPOGRAPHY, Typography } from "@/components/Typography"; -import clsx from "clsx"; -import posthog from "posthog-js"; -import { memo, useCallback, useState } from "react"; -import { toast } from "react-toastify"; -import { RedirectInput } from "./RedirectInput"; -import { useDeleteRedirectMutation } from "./graphql/client/delete-redirect.generated"; -import { - RedirectsDocument, - useRedirectsQuery, -} from "./graphql/client/fetch-redirect.generated"; -import { useInsertRedirectMutation } from "./graphql/client/insert-redirect.generated"; -import { useUpdateRedirectMutation } from "./graphql/client/update-redirect.generated"; - -export const Redirects = memo(function Redirects(props: { - actionId: string; - appId: string; - isStaging: boolean; - teamId: string; - canEdit: boolean; -}) { - const { actionId, appId, isStaging, teamId, canEdit } = props; - const [addRedirectFormShown, setAddRedirectFormShown] = useState(false); - - const { data, loading } = useRedirectsQuery({ - variables: { - action_id: actionId ?? "", - }, - }); - - const [insertRedirectMutation] = useInsertRedirectMutation(); - const [updateRedirectMutation] = useUpdateRedirectMutation(); - const [deleteRedirectMutation] = useDeleteRedirectMutation(); - - const addRedirect = useCallback( - async (redirect_uri: string) => { - try { - await insertRedirectMutation({ - variables: { - action_id: actionId, - uri: redirect_uri, - }, - - refetchQueries: [ - { - query: RedirectsDocument, - variables: { action_id: actionId }, - }, - ], - - awaitRefetchQueries: true, - }); - - setAddRedirectFormShown(false); - toast.success("Redirect added!"); - - posthog.capture("redirect_added_success", { - team_id: teamId, - app_id: appId, - }); - } catch (error) { - posthog.capture("redirect_add_failed", { - team_id: teamId, - app_id: appId, - }); - - console.error("Sign in redirects error: ", error); - toast.error("Error adding redirect"); - } - }, - [actionId, appId, insertRedirectMutation, teamId], - ); - - const deleteRedirect = useCallback( - async (id: string) => { - try { - await deleteRedirectMutation({ - variables: { - id, - }, - refetchQueries: [ - { - query: RedirectsDocument, - variables: { action_id: actionId }, - }, - ], - awaitRefetchQueries: true, - }); - toast.success("Redirect deleted!"); - } catch (error) { - console.error("Delete redirect error: ", error); - toast.error("Error deleting redirect"); - } - }, - [actionId, deleteRedirectMutation], - ); - - const redirects = data?.redirect; - - if (loading) return
; - - return ( -
- {redirects?.map((redirect) => ( - deleteRedirect(redirect.id)} - > - - - } - handleChange={(value: string) => { - if (value !== redirect.redirect_uri) { - updateRedirectMutation({ - variables: { id: redirect.id, uri: value }, - - refetchQueries: [ - { - query: RedirectsDocument, - variables: { action_id: actionId }, - }, - ], - - awaitRefetchQueries: true, - - onCompleted: () => { - toast.success("Redirect updated!"); - }, - }); - } - }} - /> - ))} - {addRedirectFormShown && ( - setAddRedirectFormShown(false)} - > - - - } - handleChange={async (value) => { - await addRedirect(value); - }} - /> - )} - setAddRedirectFormShown(true)} - > - Add another - -
- ); -}); diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.generated.ts deleted file mode 100644 index 89269062a..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.generated.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type FetchSignInActionQueryVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; -}>; - -export type FetchSignInActionQuery = { - __typename?: "query_root"; - action: Array<{ - __typename?: "action"; - id: string; - app_id: string; - status: string; - privacy_policy_uri?: string | null; - terms_uri?: string | null; - }>; - app: Array<{ __typename?: "app"; is_staging: boolean; created_at: string }>; -}; - -export const FetchSignInActionDocument = gql` - query FetchSignInAction($app_id: String!) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - id - app_id - status - privacy_policy_uri - terms_uri - } - app(where: { id: { _eq: $app_id } }) { - is_staging - created_at - } - } -`; - -/** - * __useFetchSignInActionQuery__ - * - * To run a query within a React component, call `useFetchSignInActionQuery` and pass it any options that fit your needs. - * When your component renders, `useFetchSignInActionQuery` returns an object from Apollo Client that contains loading, error, and data properties - * you can use to render your UI. - * - * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; - * - * @example - * const { data, loading, error } = useFetchSignInActionQuery({ - * variables: { - * app_id: // value for 'app_id' - * }, - * }); - */ -export function useFetchSignInActionQuery( - baseOptions: Apollo.QueryHookOptions< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - > & - ( - | { variables: FetchSignInActionQueryVariables; skip?: boolean } - | { skip: boolean } - ), -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useQuery< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - >(FetchSignInActionDocument, options); -} -export function useFetchSignInActionLazyQuery( - baseOptions?: Apollo.LazyQueryHookOptions< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useLazyQuery< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - >(FetchSignInActionDocument, options); -} -export function useFetchSignInActionSuspenseQuery( - baseOptions?: - | Apollo.SkipToken - | Apollo.SuspenseQueryHookOptions< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - >, -) { - const options = - baseOptions === Apollo.skipToken - ? baseOptions - : { ...defaultOptions, ...baseOptions }; - return Apollo.useSuspenseQuery< - FetchSignInActionQuery, - FetchSignInActionQueryVariables - >(FetchSignInActionDocument, options); -} -export type FetchSignInActionQueryHookResult = ReturnType< - typeof useFetchSignInActionQuery ->; -export type FetchSignInActionLazyQueryHookResult = ReturnType< - typeof useFetchSignInActionLazyQuery ->; -export type FetchSignInActionSuspenseQueryHookResult = ReturnType< - typeof useFetchSignInActionSuspenseQuery ->; -export type FetchSignInActionQueryResult = Apollo.QueryResult< - FetchSignInActionQuery, - FetchSignInActionQueryVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.graphql deleted file mode 100644 index 839206e76..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/fetch-sign-in-action.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query FetchSignInAction($app_id: String!) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - id - app_id - status - privacy_policy_uri - terms_uri - } - app(where: { id: { _eq: $app_id } }) { - is_staging - created_at - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.generated.ts deleted file mode 100644 index 7b2b9a3fa..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.generated.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type ResetClientSecretMutationVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; - team_id: Types.Scalars["String"]["input"]; -}>; - -export type ResetClientSecretMutation = { - __typename?: "mutation_root"; - reset_client_secret?: { - __typename?: "ResetClientOutput"; - client_secret: string; - } | null; -}; - -export const ResetClientSecretDocument = gql` - mutation ResetClientSecret($app_id: String!, $team_id: String!) { - reset_client_secret(app_id: $app_id, team_id: $team_id) { - client_secret - } - } -`; -export type ResetClientSecretMutationFn = Apollo.MutationFunction< - ResetClientSecretMutation, - ResetClientSecretMutationVariables ->; - -/** - * __useResetClientSecretMutation__ - * - * To run a mutation, you first call `useResetClientSecretMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useResetClientSecretMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [resetClientSecretMutation, { data, loading, error }] = useResetClientSecretMutation({ - * variables: { - * app_id: // value for 'app_id' - * team_id: // value for 'team_id' - * }, - * }); - */ -export function useResetClientSecretMutation( - baseOptions?: Apollo.MutationHookOptions< - ResetClientSecretMutation, - ResetClientSecretMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation< - ResetClientSecretMutation, - ResetClientSecretMutationVariables - >(ResetClientSecretDocument, options); -} -export type ResetClientSecretMutationHookResult = ReturnType< - typeof useResetClientSecretMutation ->; -export type ResetClientSecretMutationResult = - Apollo.MutationResult; -export type ResetClientSecretMutationOptions = Apollo.BaseMutationOptions< - ResetClientSecretMutation, - ResetClientSecretMutationVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.graphql deleted file mode 100644 index 901b28302..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/reset-secret.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation ResetClientSecret($app_id: String!, $team_id: String!) { - reset_client_secret(app_id: $app_id, team_id: $team_id) { - client_secret - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.generated.ts deleted file mode 100644 index 40f0eb520..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.generated.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { gql } from "@apollo/client"; -import * as Apollo from "@apollo/client"; -const defaultOptions = {} as const; -export type UpdateSignInActionMutationVariables = Types.Exact<{ - id: Types.Scalars["String"]["input"]; - input?: Types.InputMaybe; -}>; - -export type UpdateSignInActionMutation = { - __typename?: "mutation_root"; - update_action_by_pk?: { __typename?: "action"; id: string } | null; -}; - -export const UpdateSignInActionDocument = gql` - mutation UpdateSignInAction($id: String!, $input: action_set_input) { - update_action_by_pk(pk_columns: { id: $id }, _set: $input) { - id - } - } -`; -export type UpdateSignInActionMutationFn = Apollo.MutationFunction< - UpdateSignInActionMutation, - UpdateSignInActionMutationVariables ->; - -/** - * __useUpdateSignInActionMutation__ - * - * To run a mutation, you first call `useUpdateSignInActionMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useUpdateSignInActionMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [updateSignInActionMutation, { data, loading, error }] = useUpdateSignInActionMutation({ - * variables: { - * id: // value for 'id' - * input: // value for 'input' - * }, - * }); - */ -export function useUpdateSignInActionMutation( - baseOptions?: Apollo.MutationHookOptions< - UpdateSignInActionMutation, - UpdateSignInActionMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation< - UpdateSignInActionMutation, - UpdateSignInActionMutationVariables - >(UpdateSignInActionDocument, options); -} -export type UpdateSignInActionMutationHookResult = ReturnType< - typeof useUpdateSignInActionMutation ->; -export type UpdateSignInActionMutationResult = - Apollo.MutationResult; -export type UpdateSignInActionMutationOptions = Apollo.BaseMutationOptions< - UpdateSignInActionMutation, - UpdateSignInActionMutationVariables ->; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.graphql deleted file mode 100644 index 43d4075c0..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/graphql/client/update-sign-in-action.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation UpdateSignInAction($id: String!, $input: action_set_input) { - update_action_by_pk(pk_columns: { id: $id }, _set: $input) { - id - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/index.tsx deleted file mode 100644 index ab38fbe76..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/ClientInformation/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -"use client"; -import { CopyButton } from "@/components/CopyButton"; -import { DecoratedButton } from "@/components/DecoratedButton"; -import { LockIcon } from "@/components/Icons/LockIcon"; -import { Input } from "@/components/Input"; -import { TYPOGRAPHY, Typography } from "@/components/Typography"; -import { Role_Enum } from "@/graphql/graphql"; -import { ORB_APP_TEAM_ID } from "@/lib/constants"; -import { Auth0SessionUser } from "@/lib/types"; -import { checkUserPermissions } from "@/lib/utils"; -import { useUser } from "@auth0/nextjs-auth0/client"; -import clsx from "clsx"; -import { ErrorPage } from "@/components/ErrorPage"; -import { SizingWrapper } from "@/components/SizingWrapper"; -import { useCallback, useMemo, useState } from "react"; -import Skeleton from "react-loading-skeleton"; -import { toast } from "react-toastify"; -import { LinksForm } from "./Links"; -import { Redirects } from "./Redirects"; -import { useFetchSignInActionQuery } from "./graphql/client/fetch-sign-in-action.generated"; -import { useResetClientSecretMutation } from "./graphql/client/reset-secret.generated"; - -export const ClientInformationPage = (props: { - appID: string; - teamID: string; -}) => { - const { appID, teamID } = props; - const [clientSecret, setClientSecret] = useState(""); - const { user } = useUser() as Auth0SessionUser; - - const isEnoughPermissions = useMemo(() => { - return checkUserPermissions(user, teamID ?? "", [ - Role_Enum.Owner, - Role_Enum.Admin, - ]); - }, [user, teamID]); - - const { data, loading: fetchingAction } = useFetchSignInActionQuery({ - variables: { app_id: appID }, - }); - - const signInAction = data?.action[0]; - const isStaging = data?.app[0]?.is_staging; - const createdAt = data?.app[0]?.created_at; - - // Check if app was created after September 29, 2025 - const isAppCreatedAfterCutoff = useMemo(() => { - if (teamID === ORB_APP_TEAM_ID) return false; - if (!createdAt) return false; - const cutoffDate = new Date("2025-09-29T00:00:00Z"); - const appCreatedDate = new Date(createdAt); - return appCreatedDate > cutoffDate; - }, [createdAt, teamID]); - - const [resetClientSecretMutation] = useResetClientSecretMutation({ - variables: { app_id: appID, team_id: teamID }, - }); - - const handleReset = useCallback(async () => { - try { - const result = await resetClientSecretMutation(); - - if (result instanceof Error) { - throw result; - } - - setClientSecret(result.data?.reset_client_secret?.client_secret ?? ""); - toast.success("Client secret reset"); - } catch (error) { - console.error("Reset Client Secret Error: ", error); - toast.error("Failed to reset client secret"); - } - }, [resetClientSecretMutation]); - - if (fetchingAction) { - return ( -
- -
- ); - } - - if (isAppCreatedAfterCutoff) { - return ( -
-
- Feature Not Available - - Your app was created after Sign in with World ID was deprecated and - is not eligible for this feature. Please read the announcement - above. - -
-
- ); - } - - if (!fetchingAction && !signInAction) { - return ( - - - - ); - } - - return ( -
-
-
- Client information - - - Use these attributes to configure Sign in with World ID in your app - -
- -
- } - /> - - - ) : ( - <> - ) - } - addOnRight={ -
- - Reset - - - {clientSecret !== "" && ( - - )} -
- } - /> -
-
- -
-
- - Redirects - - - - You must specify at least one URL for authentication to work - -
- - -
- - -
- ); -}; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.generated.ts b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.generated.ts deleted file mode 100644 index 48ef7add3..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.generated.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable */ -import * as Types from "@/graphql/graphql"; - -import { GraphQLClient, RequestOptions } from "graphql-request"; -import gql from "graphql-tag"; -type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; -export type SignInActionQueryVariables = Types.Exact<{ - app_id: Types.Scalars["String"]["input"]; -}>; - -export type SignInActionQuery = { - __typename?: "query_root"; - action: Array<{ - __typename?: "action"; - id: string; - app_id: string; - status: string; - privacy_policy_uri?: string | null; - terms_uri?: string | null; - }>; -}; - -export const SignInActionDocument = gql` - query SignInAction($app_id: String!) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - id - app_id - status - privacy_policy_uri - terms_uri - } - } -`; - -export type SdkFunctionWrapper = ( - action: (requestHeaders?: Record) => Promise, - operationName: string, - operationType?: string, - variables?: any, -) => Promise; - -const defaultWrapper: SdkFunctionWrapper = ( - action, - _operationName, - _operationType, - _variables, -) => action(); - -export function getSdk( - client: GraphQLClient, - withWrapper: SdkFunctionWrapper = defaultWrapper, -) { - return { - SignInAction( - variables: SignInActionQueryVariables, - requestHeaders?: GraphQLClientRequestHeaders, - ): Promise { - return withWrapper( - (wrappedRequestHeaders) => - client.request(SignInActionDocument, variables, { - ...requestHeaders, - ...wrappedRequestHeaders, - }), - "SignInAction", - "query", - variables, - ); - }, - }; -} -export type Sdk = ReturnType; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.graphql b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.graphql deleted file mode 100644 index 3c86c725d..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/graphql/server/fetch-signin.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query SignInAction($app_id: String!) { - action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { - id - app_id - status - privacy_policy_uri - terms_uri - } -} diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/index.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/index.tsx deleted file mode 100644 index 3e77fc655..000000000 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/SignInWithWorldId/page/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { DecoratedButton } from "@/components/DecoratedButton"; -import { DocsIcon } from "@/components/Icons/DocsIcon"; -import { GithubIcon } from "@/components/Icons/GithubIcon"; -import { WarningErrorIcon } from "@/components/Icons/WarningErrorIcon"; -import { TYPOGRAPHY, Typography } from "@/components/Typography"; -import Image from "next/image"; -import { ClientInformationPage } from "./ClientInformation"; - -type SignInWithWorldIdPageProps = { - params: Record | null | undefined; -}; -export const SignInWithWorldIdPage = async ( - props: SignInWithWorldIdPageProps, -) => { - const { params } = props; - const appId = params?.appId as `app_${string}`; - const teamId = params?.teamId as string; - - return ( -
-
- passport -
- Sign in with World ID - - Let users sign in to your app with their World ID using OpenID - Connect (OIDC) - -
-
- - - - See an example - - - - - - Learn more - - -
-
- - -
- -
-
-
- Sign in with World ID is sunsetting in{" "} - December 2025. -
-
- New apps created after September 29, 2025 cannot enable this - feature. -
-
- Read the full announcement → -
-
-
-
-
- -
-
- -
-
- ); -}; diff --git a/web/scenes/Portal/Teams/TeamId/Apps/AppId/layout/AppIdChrome.tsx b/web/scenes/Portal/Teams/TeamId/Apps/AppId/layout/AppIdChrome.tsx index 64c2c1010..d0445376d 100644 --- a/web/scenes/Portal/Teams/TeamId/Apps/AppId/layout/AppIdChrome.tsx +++ b/web/scenes/Portal/Teams/TeamId/Apps/AppId/layout/AppIdChrome.tsx @@ -6,7 +6,6 @@ import { DashboardSquareIcon } from "@/components/Icons/DashboardSquareIcon"; import { IncognitoIcon } from "@/components/Icons/IncognitoIcon"; import { SecurityIcon } from "@/components/Icons/SecurityIcon"; import { TransactionIcon } from "@/components/Icons/TransactionIcon"; -import { UserAccountIcon } from "@/components/Icons/UserAccountIcon"; import { WalletIcon } from "@/components/Icons/WalletIcon"; import { SizingWrapper } from "@/components/SizingWrapper"; import { Tab, Tabs } from "@/components/Tabs"; @@ -272,18 +271,6 @@ export const AppIdChrome = ({ Incognito actions - {!isOnChainApp && ( - - - Sign in with World ID - - - )} - - - - - ({ - kid: "kid_my_test_key", - kms_id: "kms_my_test_id", - })), - fetchActiveJWK: jest.fn().mockImplementation(() => ({ - kid: "kid_my_test_key", - kms_id: "kms_my_test_id", - })), -}; diff --git a/web/tests/api/__mocks__/kms.mock.ts b/web/tests/api/__mocks__/kms.mock.ts index 11dd702de..e4bdb414d 100644 --- a/web/tests/api/__mocks__/kms.mock.ts +++ b/web/tests/api/__mocks__/kms.mock.ts @@ -16,7 +16,6 @@ module.exports = { }; }), })), - signJWTWithKMSKey: jest.requireActual("@/api/helpers/kms").signJWTWithKMSKey, createKMSKey: jest.fn().mockImplementation(async () => { const key = createPublicKey({ format: "jwk", key: publicJwk }); const pemKey = key.export({ type: "pkcs1", format: "pem" }); diff --git a/web/tests/api/delete-jwks.test.ts b/web/tests/api/delete-jwks.test.ts deleted file mode 100644 index 8145400d6..000000000 --- a/web/tests/api/delete-jwks.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { POST } from "@/api/_delete-jwks"; -import { logger } from "@/lib/logger"; -import { NextRequest } from "next/server"; - -let consoleInfoSpy: jest.SpyInstance; - -jest.mock("@/api/helpers/graphql", () => ({ - getAPIServiceGraphqlClient: jest.fn(), -})); -const DeleteExpiredJWKs = jest.fn(); -jest.mock("@/api/helpers/jwks/graphql/delete-expired-jwks.generated", () => ({ - getSdk: () => ({ - DeleteExpiredJWKs, - }), -})); - -beforeEach(() => { - consoleInfoSpy = jest - .spyOn(logger, "info") - .mockImplementation(async () => {}); -}); - -afterEach(() => { - consoleInfoSpy.mockRestore(); -}); - -describe("/api/v1/_delete-jwks", () => { - test("endpoint is only accessible with specific token (Hasura)", async () => { - const request = new NextRequest( - "http://localhost:3000/api/v1/_delete-jwks", - { - method: "POST", - }, - ); - - const response = await POST(request); - - expect(response?.status).toBe(403); - expect(await response?.json()).toEqual({ - code: "permission_denied", - detail: "You do not have permission to perform this action.", - attribute: null, - }); - }); - - test("will not delete jwks if none are expired", async () => { - const request = new NextRequest( - "http://localhost:3000/api/v1/_delete-jwks", - { - method: "POST", - headers: { - authorization: process.env.INTERNAL_ENDPOINTS_SECRET || "", - }, - }, - ); - - DeleteExpiredJWKs.mockResolvedValue({ - delete_jwks: { returning: [], __typename: "jwks_mutation_response" }, - }); - - const response = await POST(request); - - expect(response?.status).toBe(204); - expect(consoleInfoSpy).toHaveBeenCalledTimes(2); - expect(consoleInfoSpy).toHaveBeenNthCalledWith( - 1, - "Starting deletion of expired jwks.", - ); - expect(consoleInfoSpy).toHaveBeenNthCalledWith( - 2, - "Deleted 0 expired jwks.", - ); - }); - - test("will delete all jwks if past expiration date", async () => { - const request = new NextRequest( - "http://localhost:3000/api/v1/_delete-jwks", - { - method: "POST", - headers: { - authorization: process.env.INTERNAL_ENDPOINTS_SECRET || "", - }, - }, - ); - - DeleteExpiredJWKs.mockResolvedValue({ - delete_jwks: { - returning: [ - { - id: "jwk_4b4e07011e4766c69062b90ff384afc4", - kms_id: "f9f0ba3b-054b-4c27-bcc4-3c22c328117e", - __typename: "jwks", - }, - ], - __typename: "jwks_mutation_response", - }, - }); - - const response = await POST(request); - - expect(response?.status).toBe(204); - expect(consoleInfoSpy).toHaveBeenCalledTimes(2); - expect(consoleInfoSpy).toHaveBeenNthCalledWith( - 1, - "Starting deletion of expired jwks.", - ); - expect(consoleInfoSpy).toHaveBeenNthCalledWith( - 2, - "Deleted 1 expired jwks.", - ); - }); -}); diff --git a/web/tests/api/v1/oidc/authorize.test.ts b/web/tests/api/v1/oidc/authorize.test.ts deleted file mode 100644 index a78ea0b34..000000000 --- a/web/tests/api/v1/oidc/authorize.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { OIDCErrorCodes, OIDCScopes } from "@/api/helpers/oidc"; -import { POST } from "@/api/v1/oidc/authorize"; -import { OIDCResponseType } from "@/lib/types"; -import { createPublicKey } from "crypto"; -import dayjs from "dayjs"; -import { jwtVerify } from "jose"; -import { NextRequest } from "next/server"; -import { publicJwk } from "../../__mocks__/jwk"; -import { semaphoreProofParamsMock } from "../../__mocks__/proof.mock"; - -// Mock the external dependencies -jest.mock("@/api/helpers/graphql", () => ({ - getAPIServiceGraphqlClient: jest.fn(), -})); - -jest.mock("@/api/helpers/kms", () => - require("tests/api/__mocks__/kms.mock.ts"), -); - -jest.mock("@/api/helpers/jwks", () => - require("tests/api/__mocks__/jwks.mock.ts"), -); - -// Mock the GraphQL SDKs -const FetchOIDCApp = jest.fn(); -const Nullifier = jest.fn(); -const UpsertNullifier = jest.fn(); -const InsertAuthCode = jest.fn(); - -jest.mock("@/api/helpers/oidc/graphql/fetch-oidc-app.generated", () => ({ - getSdk: () => ({ - FetchOIDCApp, - }), -})); - -jest.mock("@/api/v1/oidc/authorize/graphql/fetch-nullifier.generated", () => ({ - getSdk: () => ({ - Nullifier, - }), -})); - -jest.mock("@/api/v1/oidc/authorize/graphql/upsert-nullifier.generated", () => ({ - getSdk: () => ({ - UpsertNullifier, - }), -})); - -jest.mock("@/api/helpers/oidc/graphql/insert-auth-code.generated", () => ({ - getSdk: () => ({ - InsertAuthCode, - }), -})); - -// Mock the verifyProof function -jest.mock("@/api/helpers/verify", () => ({ - verifyProof: jest.fn().mockResolvedValue({ error: null }), -})); - -beforeEach(async () => { - await global.RedisClient?.flushall(); - - // Mock OIDC app fetch - FetchOIDCApp.mockResolvedValue({ - app: [ - { - id: "app_112233445566778", - is_staging: false, - actions: [ - { - id: "action_staging_112233445566778", - action: "", - status: "active", - external_nullifier: - "0x1c75ff6366690115808bd58e4c6e3342068088703dffa0a0ee07f55892bb10bd", - redirects: [ - { - redirect_uri: "https://example.com/cb", - }, - ], - }, - ], - }, - ], - }); - - // Mock nullifier operations - Nullifier.mockResolvedValue({ nullifier: [] }); - UpsertNullifier.mockResolvedValue({ - insert_nullifier_one: { nullifier_hash: "0x123", id: "nil_123" }, - }); - - // Mock auth code insertion - InsertAuthCode.mockImplementation((args) => ({ - insert_auth_code_one: { auth_code: args.auth_code }, - })); -}); - -const VALID_REQUEST: Record = { - ...semaphoreProofParamsMock, - app_id: "app_1234", - scope: OIDCScopes.OpenID, - response_type: OIDCResponseType.Code, - redirect_uri: "https://example.com/cb", -}; - -describe("/api/v1/oidc/authorize [request validation]", () => { - test("validate required attributes", async () => { - const required_attributes = [ - "proof", - "nullifier_hash", - "merkle_root", - "verification_level", - "app_id", - "response_type", - "redirect_uri", - ]; - for (const attribute of required_attributes) { - const body = { ...VALID_REQUEST, [attribute]: undefined }; - delete body[attribute]; - const req = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify(body), - }, - ); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toMatchObject({ - code: "validation_error", - attribute, - detail: "This attribute is required.", - }); - } - }); - - test("openid scope is always required for OIDC requests", async () => { - const invalid_scopes = ["invalid", "profile%20email", undefined, ""]; - for (const scope of invalid_scopes) { - const req = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST, scope }), - }, - ); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toMatchObject({ - attribute: "scope", - detail: "The openid scope is always required.", - }); - } - }); - - test("invalid response_type throws an error", async () => { - const invalid_response_types = [ - "invalid", - "code%20invalid", - "code invalid", - ]; - for (const response_type of invalid_response_types) { - const req = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST, response_type }), - }, - ); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toMatchObject({ - attribute: "response_type", - code: OIDCErrorCodes.UnsupportedResponseType, - }); - } - }); - - test("validate redirect_uri", async () => { - const invalid_redirect_uris = [ - "http://example.com/cb", - "https://example.com/cb?query=string", - "https://example.com", - "https://evil.com", - ]; - for (const redirect_uri of invalid_redirect_uris) { - const req = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST, redirect_uri }), - }, - ); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toMatchObject({ - attribute: "redirect_uri", - detail: "Invalid redirect URI.", - code: OIDCErrorCodes.InvalidRedirectURI, - }); - } - }); -}); - -describe("/api/v1/oidc/authorize [authorization code flow]", () => { - test("returns an authorization code", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST }), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - code: expect.stringMatching(/^[a-f0-9]{16,30}$/), - }); - }); - - test("prevents replayed proofs", async () => { - const req1 = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST }), - }, - ); - - const req2 = new NextRequest( - "http://localhost:3000/api/v1/oidc/authorize", - { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST }), - }, - ); - - await POST(req1); - const response = await POST(req2); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toMatchObject({ - code: "invalid_proof", - attribute: "proof", - detail: "This proof has already been used. Please try again", - }); - }); -}); - -describe("/api/v1/oidc/authorize [implicit flow]", () => { - test("returns a valid token", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - body: JSON.stringify({ ...VALID_REQUEST, response_type: "id_token" }), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ id_token: expect.any(String) }); - - const jwt = data.id_token; - const publicKey = createPublicKey({ format: "jwk", key: publicJwk }); - const { protectedHeader, payload } = await jwtVerify(jwt, publicKey); - - expect(protectedHeader).toEqual({ - alg: "RS256", - kid: "kid_my_test_key", - typ: "JWT", - }); - - expect(payload).toEqual({ - iss: "https://id.worldcoin.org", - sub: semaphoreProofParamsMock.nullifier_hash, - jti: expect.any(String), - iat: expect.any(Number), - exp: expect.any(Number), - aud: "app_112233445566778", - scope: "openid", - "https://id.worldcoin.org/beta": { - likely_human: "strong", - credential_type: "orb", - warning: - "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", - }, - "https://id.worldcoin.org/v1": { - verification_level: "orb", - }, - nonce: semaphoreProofParamsMock.signal, - }); - - // Validate timestamps - const iatDiff = Math.abs(dayjs().diff(dayjs.unix(payload.iat!), "second")); - const oneHourFromNow = new Date().getTime() + 60 * 60 * 1000; - - const expDiff = Math.abs(oneHourFromNow / 1000 - payload.exp!); - expect(iatDiff).toBeLessThan(2); // 2 sec - expect(expDiff).toBeLessThan(2); // 2 sec - expect(payload.iat!.toString().length).toEqual(10); // timestamp in seconds has 10 digits - }); -}); - -describe("/api/v1/oidc/authorize [hybrid flow]", () => { - test("returns a valid token and authorization code", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - body: JSON.stringify({ - ...VALID_REQUEST, - response_type: "code id_token", - }), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - id_token: expect.any(String), - code: expect.stringMatching(/^[a-f0-9]{16,30}$/), - }); - - const jwt = data.id_token; - const publicKey = createPublicKey({ format: "jwk", key: publicJwk }); - const { protectedHeader, payload } = await jwtVerify(jwt, publicKey); - - expect(protectedHeader).toEqual({ - alg: "RS256", - kid: "kid_my_test_key", - typ: "JWT", - }); - - expect(payload).toEqual({ - iss: "https://id.worldcoin.org", - sub: semaphoreProofParamsMock.nullifier_hash, - jti: expect.any(String), - iat: expect.any(Number), - exp: expect.any(Number), - aud: "app_112233445566778", - scope: "openid", - "https://id.worldcoin.org/beta": { - likely_human: "strong", - credential_type: "orb", - warning: - "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", - }, - "https://id.worldcoin.org/v1": { - verification_level: "orb", - }, - nonce: semaphoreProofParamsMock.signal, - }); - - // Validate timestamps - const iatDiff = Math.abs(dayjs().diff(dayjs.unix(payload.iat!), "second")); - const oneHourFromNow = new Date().getTime() + 60 * 60 * 1000; - - const expDiff = Math.abs(oneHourFromNow / 1000 - payload.exp!); - expect(iatDiff).toBeLessThan(2); // 2 sec - expect(expDiff).toBeLessThan(2); // 2 sec - expect(payload.iat!.toString().length).toEqual(10); // timestamp in seconds has 10 digits - }); -}); diff --git a/web/tests/api/v1/oidc/userinfo.test.ts b/web/tests/api/v1/oidc/userinfo.test.ts deleted file mode 100644 index 6c881f8c4..000000000 --- a/web/tests/api/v1/oidc/userinfo.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { generateOIDCJWT } from "@/api/helpers/jwts"; -import { OIDCScopes } from "@/api/helpers/oidc"; -import { VerificationLevel } from "@worldcoin/idkit-core"; - -import { POST } from "@/api/v1/oidc/userinfo"; -import { NextRequest } from "next/server"; - -jest.mock("@/api/helpers/kms", () => - require("tests/api/__mocks__/kms.mock.ts"), -); - -jest.mock("@/api/helpers/jwks", () => - require("tests/api/__mocks__/jwks.mock.ts"), -); - -describe("/api/v1/oidc/userinfo", () => { - test("invalid jwt", async () => { - const jwt = await generateOIDCJWT({ - kid: "test-key", - nonce: "1234", - app_id: "app_1234", - kms_id: "test-kms-id", - nullifier_hash: "0x00000", - verification_level: VerificationLevel.Orb, - scope: [OIDCScopes.OpenID, OIDCScopes.Profile], - }); - // Ensure we're actually generating a JWT - expect(jwt).toMatch(/^[\w-]*\.[\w-]*\.[\w-]*$/); - - const req = new NextRequest("http://localhost:3000/api/v1/oidc/userinfo", { - method: "POST", - headers: { - authorization: `Bearer ${jwt}`, - }, - }); - - const response = await POST(req); - - expect(response.status).toBe(401); - const responseBody = await response.json(); - expect(responseBody).toMatchObject({ - attribute: "token", - code: "invalid_token", - }); - }); -}); diff --git a/web/tests/api/v1/oidc/validate.test.ts b/web/tests/api/v1/oidc/validate.test.ts deleted file mode 100644 index 12de15ab2..000000000 --- a/web/tests/api/v1/oidc/validate.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { OIDCErrorCodes } from "@/api/helpers/oidc"; -import { POST } from "@/api/v1/oidc/validate"; -import { NextRequest } from "next/server"; - -jest.mock("@/api/helpers/graphql", () => ({ - getAPIServiceGraphqlClient: jest.fn(), -})); -const FetchOIDCApp = jest.fn(); -jest.mock("@/api/helpers/oidc/graphql/fetch-oidc-app.generated", () => ({ - getSdk: () => ({ - FetchOIDCApp, - }), -})); -beforeEach(() => { - FetchOIDCApp.mockResolvedValue({ - app: [ - { - id: "app_0123456789", - is_staging: true, - actions: [ - { - external_nullifier: "external_nullifier", - redirects: [ - { - redirect_uri: "https://example.com", - }, - ], - }, - ], - }, - ], - cache: [ - { - key: "staging.semaphore.wld.eth", - value: "0x000000000000000000000", - }, - ], - }); -}); - -describe("/api/v1/oidc/validate", () => { - test("can validate app and redirect_uri", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { - method: "POST", - body: JSON.stringify({ - app_id: "app_0123456789", - redirect_uri: "https://example.com", - }), - }); - - const response = await POST(req); - - expect(response.status).toBe(200); - const responseBody = await response.json(); - expect(responseBody).toMatchObject({ - app_id: "app_0123456789", - redirect_uri: "https://example.com", - }); - }); - - test("invalid app_id", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { - method: "POST", - body: JSON.stringify({ - app_id: "app_invalid", - redirect_uri: "https://example.com", - }), - }); - - FetchOIDCApp.mockResolvedValueOnce({ - app: [], - cache: [ - { - key: "staging.semaphore.wld.eth", - value: "0x000000000000000000000", - }, - ], - }); - - const response = await POST(req); - - expect(response.status).toBe(404); - const responseBody = await response.json(); - expect(responseBody).toMatchObject({ - attribute: "app_id", - code: "app_not_found", - }); - }); - - test("invalid redirect_uri", async () => { - const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { - method: "POST", - body: JSON.stringify({ - app_id: "app_0123456789", - redirect_uri: "https://invalid.com", - }), - }); - - const response = await POST(req); - - expect(response.status).toBe(400); - const responseBody = await response.json(); - expect(responseBody).toMatchObject({ - code: OIDCErrorCodes.InvalidRedirectURI, - attribute: "redirect_uri", - }); - }); -}); diff --git a/web/tests/integration/action.test.ts b/web/tests/integration/action.test.ts index 8a1bd1df4..59c7229f9 100644 --- a/web/tests/integration/action.test.ts +++ b/web/tests/integration/action.test.ts @@ -29,17 +29,10 @@ describe("service role", () => { }), ); - let signInWithWorldIDCount = 0; - for (const row of response.data.action) { // Service role should not see archived actions expect(row.app_id).not.toEqual(rows[0].id); - if (row.name === "Sign in with World ID") { - signInWithWorldIDCount++; - } } - - expect(signInWithWorldIDCount).toEqual(1); // only one app with sign in with world id }); test("can query return_to fields from actions", async () => { diff --git a/web/tests/integration/app.test.ts b/web/tests/integration/app.test.ts index 56a6f3f74..587202b80 100644 --- a/web/tests/integration/app.test.ts +++ b/web/tests/integration/app.test.ts @@ -2,7 +2,6 @@ import { gql } from "@apollo/client"; import { integrationDBClean, integrationDBExecuteQuery } from "./setup"; -import { POST } from "@/api/hasura/reset-client-secret"; import { NextRequest } from "next/server"; import { getAPIClient, getAPIUserClient } from "./test-utils"; // TODO: Consider moving this to a generalized jest environment @@ -158,133 +157,6 @@ describe("user role", () => { } } }); - - test("cannot reset client secret as a member", async () => { - const { rows: teams } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."team";`, - )) as { rows: Array<{ id: string }> }; - - const { rows: teamMemberships } = (await integrationDBExecuteQuery( - `SELECT id, user_id, team_id FROM "public"."membership" WHERE "team_id" = '${teams[0].id}' AND "role" = 'MEMBER' limit 1;`, - )) as { rows: Array<{ id: string; user_id: string; team_id: string }> }; - - const { rows: teamApps } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."app" WHERE "team_id" = '${teams[0].id}' limit 1;`, - )) as { rows: Array<{ id: string }> }; - - // Test invalid role - const tokenTeamId = teams[0].id; - const tokenUserId = teamMemberships[0].user_id; - const appId = teamApps[0].id; - - const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: process.env.INTERNAL_ENDPOINTS_SECRET!, - }, - body: JSON.stringify({ - input: { app_id: appId, team_id: tokenTeamId }, - action: { name: "reset_client_secret" }, - session_variables: { - "x-hasura-role": "user", - "x-hasura-user-id": tokenUserId, - }, - }), - }, - ); - - const res = await POST(req); - - const responseJSON = await res?.json(); - expect(responseJSON.extensions.code).toBe("insufficient_permissions"); - }); - - test("cannot reset client secret for another team", async () => { - const { rows: teams } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."team";`, - )) as { rows: Array<{ id: string }> }; - - const { rows: teamMemberships } = (await integrationDBExecuteQuery( - `SELECT id, user_id, team_id FROM "public"."membership" WHERE "team_id" = '${teams[1].id}' AND "role" = 'OWNER' limit 1;`, - )) as { rows: Array<{ id: string; user_id: string; team_id: string }> }; - - const { rows: teamApps } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."app" WHERE "team_id" = '${teams[0].id}' limit 1;`, - )) as { rows: Array<{ id: string }> }; - - const tokenTeamId = teams[1].id; - const tokenUserId = teamMemberships[0].user_id; - const appId = teamApps[0].id; - - const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: process.env.INTERNAL_ENDPOINTS_SECRET!, - }, - body: JSON.stringify({ - input: { app_id: appId, team_id: tokenTeamId }, - action: { name: "reset_client_secret" }, - session_variables: { - "x-hasura-role": "user", - "x-hasura-user-id": tokenUserId, - }, - }), - }, - ); - - const res = await POST(req); - - const responseJSON = await res?.json(); - expect(responseJSON.extensions.code).toBe("insufficient_permissions"); - }); - - test("can reset client secret", async () => { - const { rows: teams } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."team";`, - )) as { rows: Array<{ id: string }> }; - - const { rows: teamMemberships } = (await integrationDBExecuteQuery( - `SELECT id, user_id, team_id FROM "public"."membership" WHERE "team_id" = '${teams[0].id}' AND "role" = 'OWNER' limit 1;`, - )) as { rows: Array<{ id: string; user_id: string; team_id: string }> }; - - const { rows: teamApps } = (await integrationDBExecuteQuery( - `SELECT id FROM "public"."app" WHERE "team_id" = '${teams[0].id}' limit 1;`, - )) as { rows: Array<{ id: string }> }; - - const tokenTeamId = teams[0].id; - const tokenUserId = teamMemberships[0].user_id; - const appId = teamApps[0].id; - - const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: process.env.INTERNAL_ENDPOINTS_SECRET!, - }, - body: JSON.stringify({ - input: { app_id: appId, team_id: tokenTeamId }, - action: { name: "reset_client_secret" }, - session_variables: { - "x-hasura-role": "user", - "x-hasura-user-id": tokenUserId, - }, - }), - }, - ); - - const res = await POST(req); - - const responseJSON = await res?.json(); - expect(responseJSON.client_secret).toBeDefined(); - }); }); describe("api_key role", () => { diff --git a/web/tests/integration/jwks.test.ts b/web/tests/integration/jwks.test.ts deleted file mode 100644 index e46ab4331..000000000 --- a/web/tests/integration/jwks.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { fetchActiveJWK, generateJWK, retrieveJWK } from "@/api/helpers/jwks"; -import { createKMSKey, getKMSClient } from "@/api/helpers/kms"; -import { integrationDBClean, integrationDBExecuteQuery } from "./setup"; - -jest.mock("@/api/helpers/kms", () => { - return { - getKMSClient: jest.fn(), - createKMSKey: jest.fn(), - scheduleKeyDeletion: jest.fn(), - }; -}); - -jest.mock("@/api/helpers/kms", () => - require("tests/api/__mocks__/kms.mock.ts"), -); - -beforeEach(integrationDBClean); -describe("jwks management", () => { - it("can retrieve existing jwks", async () => { - const { rows } = await integrationDBExecuteQuery( - 'SELECT * FROM "public"."jwks" LIMIT 1;', - ); - - const jwk = await retrieveJWK(rows[0].id); - expect(jwk.kid).toEqual(rows[0].id); - expect(jwk.kms_id).toEqual(rows[0].kms_id); - }); - - it("throws error if the jwk is not found", async () => { - await expect(retrieveJWK("non-existing-jwk")).rejects.toThrowError( - "JWK not found.", - ); - }); - - it("fetches an active jwk", async () => { - const { rows } = await integrationDBExecuteQuery( - 'SELECT * FROM "public"."jwks" LIMIT 1;', - ); - - const jwk = await fetchActiveJWK(); - expect(jwk.kid).toEqual(rows[0].id); - }); - - it("does not rotate a jwk with more than 7 days to expire", async () => { - const { rows } = await integrationDBExecuteQuery( - 'SELECT * FROM "public"."jwks" WHERE "expires_at" > NOW() + INTERVAL \'7 days\' LIMIT 1;', - ); - - const jwk = await fetchActiveJWK(); - expect(jwk.kid).toEqual(rows[0].id); - }); - - it("rotates a jwk with less than 7 days to expire", async () => { - const { rows } = await integrationDBExecuteQuery( - 'UPDATE "public"."jwks" SET "expires_at" = NOW() + INTERVAL \'6 days\' WHERE "id" = (SELECT id FROM "public"."jwks" LIMIT 1) RETURNING "id";', - ); - - const jwk = await fetchActiveJWK(); - expect(jwk.kid).not.toEqual(rows[0].id); - }); - - it("can generate new kms keys", async () => { - // Mock the responses for KMS functions - (getKMSClient as jest.Mock).mockReturnValue(true); - (createKMSKey as jest.Mock).mockReturnValue({ - keyId: "da112a8b-023d-4eda-ae7d-33fde0a721b4", - publicKey: `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvzV3R48ve50etEd4BtryHzo1x1h1tC1poHkSXGzjXPIXmYvuLyZPCWfNzuH9YpXfuZRch1p3YrFRavSoQClb/kfAOou/nZXPyFdVlhzQzLp0EGB+/WEjA5Zj4J39EDdyToXmxsVNezzZJG66kfhz1VmBd18WGGAPDvw9PAdR2LpybKXl9VvwY5CFHazkadFy8Any+nKHpn3R3MxRHaeJV3EZDJfC+C46BCULkAS8EnZAtfdTJubIE71cNoOu/WmQupYsotk1XT3aN07ctvYuhyejiE+6bU3awre/kOumyjzb/7UWeIMvwxbFor3fEUPJa70xFfqPJUpFyj8NXlPE5wIDAQAB ------END PUBLIC KEY-----`, - }); - - const result = await generateJWK(); - expect(result.keyId).toEqual("da112a8b-023d-4eda-ae7d-33fde0a721b4"); - }); - - it("throws error if kms client cannot be created", async () => { - // Mock the responses for KMS functions - (getKMSClient as jest.Mock).mockReturnValue(false); - - await expect(generateJWK()).rejects.toThrowError("KMS client not found."); - }); - - it("throws error if kms key generation fails", async () => { - // Mock the responses for KMS functions - (getKMSClient as jest.Mock).mockReturnValue(true); - (createKMSKey as jest.Mock).mockReturnValue({}); - - await expect(generateJWK()).rejects.toThrowError( - "Unable to create KMS key.", - ); - }); -}); diff --git a/web/tests/integration/oidc/authorize.test.ts b/web/tests/integration/oidc/authorize.test.ts deleted file mode 100644 index d7a89f7b2..000000000 --- a/web/tests/integration/oidc/authorize.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { OIDCErrorCodes } from "@/api/helpers/oidc"; -import { POST } from "@/api/v1/oidc/authorize"; -import { createHash } from "crypto"; -import { NextRequest } from "next/server"; -import { semaphoreProofParamsMock } from "tests/api/__mocks__/proof.mock"; -import { integrationDBClean, integrationDBExecuteQuery } from "../setup"; -import { testGetDefaultApp } from "../test-utils"; - -// Mock the verifyProof function -jest.mock("@/api/helpers/verify", () => ({ - verifyProof: jest.fn().mockResolvedValue({ error: null }), -})); - -beforeEach(async () => { - await integrationDBClean(); - await global.RedisClient?.flushall(); -}); - -const pkceChallenge = (code_verifier: string) => { - return createHash("sha256") - .update(code_verifier) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -}; - -const validParams = (app_id: string, pkce = false) => - ({ - // proof verification is mocked - ...semaphoreProofParamsMock, - app_id: app_id, - scope: "openid email", - response_type: "code", - redirect_uri: "http://localhost:3000/login", - state: "my_state", - ...(pkce - ? { - code_challenge: pkceChallenge("my_code_challenge"), - code_challenge_method: "S256", - } - : {}), - }) as Record; - -// TODO: Add additional test cases -describe("/api/v1/oidc/authorize", () => { - test("can get an auth code", async () => { - const dbQuery = await integrationDBExecuteQuery( - "SELECT * FROM app JOIN app_metadata ON app.id = app_metadata.app_id WHERE app_metadata.name = 'Sign In App' LIMIT 1;", - ); - const app_id = dbQuery.rows[0].app_id; - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(validParams(app_id)), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - code: expect.stringMatching(/^[a-f0-9]{16,30}$/), - }); - }); - - test("`redirect_uri` is required", async () => { - const app_id = await testGetDefaultApp(); - - const params = validParams(app_id); - delete params.redirect_uri; - - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(params), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toEqual({ - attribute: "redirect_uri", - code: "validation_error", - detail: "This attribute is required.", - }); - }); - - test("invalid `redirect_uri` is rejected", async () => { - const app_id = await testGetDefaultApp(); - - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...validParams(app_id), - redirect_uri: "https://example.com/invalid", - }), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(400); - expect(data).toEqual({ - attribute: "redirect_uri", - code: OIDCErrorCodes.InvalidRedirectURI, - detail: "Invalid redirect URI.", - app_id, - }); - }); - - test("can get an auth code with PKCE", async () => { - const dbQuery = await integrationDBExecuteQuery( - "SELECT * FROM app JOIN app_metadata ON app.id = app_metadata.app_id WHERE app_metadata.name = 'Sign In App' LIMIT 1;", - ); - const app_id = dbQuery.rows[0].app_id; - const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(validParams(app_id, true)), - }); - - const response = await POST(req); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toEqual({ - code: expect.stringMatching(/^[a-f0-9]{16,30}$/), - }); - - const code = data.code; - - const { rows } = await integrationDBExecuteQuery( - `SELECT * FROM public.auth_code WHERE auth_code = '${code}' LIMIT 1;`, - ); - const { code_challenge, code_challenge_method } = rows[0]; - - expect(code_challenge_method).toEqual("S256"); - expect(code_challenge).toEqual(pkceChallenge("my_code_challenge")); - }); -}); diff --git a/web/tests/integration/oidc/token.test.ts b/web/tests/integration/oidc/token.test.ts deleted file mode 100644 index b553a07f9..000000000 --- a/web/tests/integration/oidc/token.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -import { POST } from "@/api/v1/oidc/token"; -import { createHash } from "crypto"; -import * as jose from "jose"; -import { NextRequest, NextResponse } from "next/server"; -import { publicJwk } from "tests/api/__mocks__/jwk"; -import { integrationDBClean, integrationDBExecuteQuery } from "../setup"; -import { setClientSecret, testGetSignInApp } from "../test-utils"; - -jest.mock("@/api/helpers/kms", () => - require("tests/api/__mocks__/kms.mock.ts"), -); - -const pkceChallenge = (code_verifier: string) => { - return createHash("sha256") - .update(code_verifier) - .digest("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -}; - -beforeEach(async () => await integrationDBClean()); - -describe("/api/v1/oidc/token", () => { - test("can exchange one-time auth code", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const data = await response.json(); - const { access_token, id_token, token_type, expires_in, scope } = data; - expect(access_token).toBeTruthy(); - expect(id_token).toEqual(access_token); - expect(token_type).toEqual("Bearer"); - expect(expires_in).toEqual(3600); - expect(scope).toEqual("openid email"); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - - // Make sure the proper error response is now sent - const request2 = new NextRequest( - "http://localhost:3000/api/v1/oidc/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }, - ); - - const response2 = (await POST(request2)) as NextResponse; - expect(response2.status).toBe(400); - const errorData = await response2.json(); - expect(errorData).toEqual( - expect.objectContaining({ - detail: "Invalid authorization code.", - code: "invalid_grant", - }), - ); - }); - - test("access_token is valid", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, verification_level, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "orb", - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token } = await response.json(); - - const { payload } = await jose.jwtVerify( - access_token, - await jose.importJWK(publicJwk, "RS256"), - { - issuer: process.env.JWT_ISSUER, - }, - ); - - expect(payload).toEqual( - expect.objectContaining({ - sub: "0x000000000000000111111111111", - aud: app_id, - iss: process.env.JWT_ISSUER, - exp: expect.any(Number), - iat: expect.any(Number), - jti: expect.any(String), - scope: "openid email", - email: "0x000000000000000111111111111@id.worldcoin.org", - "https://id.worldcoin.org/beta": expect.objectContaining({ - likely_human: "strong", - credential_type: "orb", - }), - "https://id.worldcoin.org/v1": { - verification_level: "orb", - }, - }), - ); - }); - - test("form-urlencoded with UTF-8 charset is accepted", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, verification_level, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "orb", - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token } = await response.json(); - - const { payload } = await jose.jwtVerify( - access_token, - await jose.importJWK(publicJwk, "RS256"), - { - issuer: process.env.JWT_ISSUER, - }, - ); - - expect(payload).toEqual( - expect.objectContaining({ - sub: "0x000000000000000111111111111", - aud: app_id, - iss: process.env.JWT_ISSUER, - exp: expect.any(Number), - iat: expect.any(Number), - jti: expect.any(String), - scope: "openid email", - email: "0x000000000000000111111111111@id.worldcoin.org", - "https://id.worldcoin.org/beta": expect.objectContaining({ - likely_human: "strong", - credential_type: "orb", - }), - "https://id.worldcoin.org/v1": { - verification_level: "orb", - }, - }), - ); - }); - - test("successfully validates PKCE", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code with PKCE - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - pkceChallenge("my_code_challenge"), - "S256", - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - code_verifier: "my_code_challenge", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token, id_token, token_type, expires_in, scope } = - await response.json(); - expect(access_token).toBeTruthy(); - expect(id_token).toEqual(access_token); - expect(token_type).toEqual("Bearer"); - expect(expires_in).toEqual(3600); - expect(scope).toEqual("openid email"); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("rejects invalid PKCE", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code with PKCE - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - pkceChallenge("my_code_challenge"), - "S256", - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - code_verifier: "invalid_code_challenge", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toEqual({ - attribute: "code_verifier", - code: "invalid_request", - detail: "Invalid code verifier.", - error: "invalid_request", - error_description: "Invalid code verifier.", - }); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("prevent PKCE downgrade", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code with PKCE - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - pkceChallenge("my_code_challenge"), - "S256", - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toEqual({ - attribute: "code_verifier", - code: "invalid_request", - detail: "Missing code verifier.", - error: "invalid_request", - error_description: "Missing code verifier.", - }); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("error when PKCE not expected", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code with PKCE - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - code_verifier: "my_code_challenge", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toEqual({ - code: "invalid_request", - error: "invalid_request", - attribute: "code_verifier", - detail: "Code verifier was not expected.", - error_description: "Code verifier was not expected.", - }); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("properly sets CORS headers", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code with PKCE - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - pkceChallenge("my_code_challenge"), - "S256", - "http://localhost:3000/login", - ], - ); - - const notPKCERequest = new NextRequest( - "http://localhost:3000/api/v1/oidc/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_secret, - client_id: app_id, - code: "83a313c5939399ba017d2381", - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }, - ); - - const notPKCEResponse = (await POST(notPKCERequest)) as NextResponse; - expect( - notPKCEResponse.headers.get("access-control-allow-origin"), - ).toBeNull(); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - code_verifier: "my_code_challenge", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.headers.get("access-control-allow-origin")).toEqual("*"); - }); - - test("successfully validates single redirect_uri", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token, id_token, token_type, expires_in, scope } = - await response.json(); - expect(access_token).toBeTruthy(); - expect(id_token).toEqual(access_token); - expect(token_type).toEqual("Bearer"); - expect(expires_in).toEqual(3600); - expect(scope).toEqual("openid email"); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("allows no redirect_uri when only one set", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token, id_token, token_type, expires_in, scope } = - await response.json(); - expect(access_token).toBeTruthy(); - expect(id_token).toEqual(access_token); - expect(token_type).toEqual("Bearer"); - expect(expires_in).toEqual(3600); - expect(scope).toEqual("openid email"); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); - - test("blocks no redirect_uri when multiple set", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // insert second redirect_uri - await integrationDBExecuteQuery( - "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", - [app_id, "http://localhost:3000/login2"], - ); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(400); - - const { code, detail, attribute } = await response.json(); - expect(code).toEqual("invalid_request"); - expect(detail).toEqual("Missing redirect URI."); - expect(attribute).toEqual("redirect_uri"); - }); - - test("blocks wrong redirect_uri when multiple set", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // insert second redirect_uri - await integrationDBExecuteQuery( - "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", - [app_id, "http://localhost:3000/login2"], - ); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login2", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(400); - - const { code, detail, attribute } = await response.json(); - expect(code).toEqual("invalid_request"); - expect(detail).toEqual("Invalid redirect URI."); - expect(attribute).toEqual("redirect_uri"); - }); - - test("accepts correct redirect_uri when multiple set", async () => { - const app_id = await testGetSignInApp(); - const { client_secret } = await setClientSecret(app_id); - - // insert second redirect_uri - await integrationDBExecuteQuery( - "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", - [app_id, "http://localhost:3000/login2"], - ); - - // Insert a valid auth code - await integrationDBExecuteQuery( - "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", - [ - app_id, - "83a313c5939399ba017d2381", - "2030-09-01T00:00:00.000Z", - "0x000000000000000111111111111", - '["openid", "email"]', - "http://localhost:3000/login", - ], - ); - - const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - code: "83a313c5939399ba017d2381", - client_id: app_id, - client_secret, - grant_type: "authorization_code", - redirect_uri: "http://localhost:3000/login", - }), - }); - - const response = (await POST(request)) as NextResponse; - expect(response.status).toBe(200); - - const { access_token, id_token, token_type, expires_in, scope } = - await response.json(); - expect(access_token).toBeTruthy(); - expect(id_token).toEqual(access_token); - expect(token_type).toEqual("Bearer"); - expect(expires_in).toEqual(3600); - expect(scope).toEqual("openid email"); - - // Verify that the auth code is deleted - const result = await integrationDBExecuteQuery( - "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", - [app_id, "83a313c5939399ba017d2381"], - ); - expect(result.rowCount).toEqual(0); - }); -}); diff --git a/web/tests/integration/team.test.ts b/web/tests/integration/team.test.ts index 1b00bb3e8..08a63255c 100644 --- a/web/tests/integration/team.test.ts +++ b/web/tests/integration/team.test.ts @@ -183,7 +183,7 @@ describe("user role", () => { const memberRoleTeamId = memberRoleMemberships[0].team_id; const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, + `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/invite-team-members`, { method: "POST", headers: { @@ -219,7 +219,7 @@ describe("user role", () => { const tokenTeamId = teams[1].id; const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, + `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/invite-team-members`, { method: "POST", headers: { @@ -255,7 +255,7 @@ describe("user role", () => { const tokenTeamId = teamMemberships[0].team_id; const req = new NextRequest( - `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/reset-client-secret`, + `${process.env.NEXT_PUBLIC_APP_URL}/api/hasura/invite-team-members`, { method: "POST", headers: { diff --git a/web/tests/unit/check-flow-type.test.ts b/web/tests/unit/check-flow-type.test.ts deleted file mode 100644 index b58fbb226..000000000 --- a/web/tests/unit/check-flow-type.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { checkFlowType } from "@/api/helpers/oidc"; -import { OIDCFlowType } from "@/lib/types"; - -describe("Check flow type", () => { - test("Detects authorization code flow", () => { - const validResponseTypes = ["code"]; - - validResponseTypes.forEach((responseType) => { - const responseTypes = responseType.split(" "); - expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.AuthorizationCode); - }); - }); - - test("Detects implicit flow", () => { - const validResponseTypes = ["token id_token", "id_token token", "id_token"]; - validResponseTypes.forEach((responseType) => { - const responseTypes = responseType.split(" "); - expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Implicit); - }); - }); - - test("Detects hybrid flow", () => { - const validResponseTypes = [ - "code id_token", - "id_token code", - "code token", - "token code", - "code id_token token", - "code token id_token", - "token code id_token", - "token id_token code", - "id_token code token", - "id_token token code", - ]; - - validResponseTypes.forEach((responseType) => { - const responseTypes = responseType.split(" "); - expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Hybrid); - }); - }); - - test("Detects `token` flow", () => { - const validResponseTypes = ["token"]; - - validResponseTypes.forEach((responseType) => { - const responseTypes = responseType.split(" "); - expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Token); - }); - }); - - test("Detects invalid flow", () => { - const invalidResponseTypes = ["value", "value1 value2"]; - - invalidResponseTypes.forEach((responseType) => { - const responseTypes = responseType.split(" "); - expect(checkFlowType(responseTypes)).toBe(null); - }); - }); -}); From 46dae704b4e1cda7df1996ad8adbaa68ebe19c2c Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:38:27 -0800 Subject: [PATCH 3/6] remove unused OIDC types and dead KMS code --- web/api/helpers/kms.ts | 46 ------------------------------------------ web/lib/types.ts | 14 ------------- 2 files changed, 60 deletions(-) diff --git a/web/api/helpers/kms.ts b/web/api/helpers/kms.ts index 7779c6c41..d2fe83125 100644 --- a/web/api/helpers/kms.ts +++ b/web/api/helpers/kms.ts @@ -6,63 +6,17 @@ import "server-only"; import { logger } from "@/lib/logger"; import { - CreateKeyCommand, DescribeKeyCommand, - GetPublicKeyCommand, KMSClient, - KeySpec, ScheduleKeyDeletionCommand, } from "@aws-sdk/client-kms"; -export type CreateKeyResult = - | { - keyId: string; - publicKey: string; - createdAt: Date; - } - | undefined; - export const getKMSClient = async (region?: string) => { return new KMSClient({ region: region ?? process.env.AWS_REGION_NAME, }); }; -export const createKMSKey = async ( - client: KMSClient, - alg: KeySpec, -): Promise => { - try { - const { KeyMetadata } = await client.send( - new CreateKeyCommand({ - KeySpec: alg, - KeyUsage: "SIGN_VERIFY", - Description: `Developer Portal JWK for Sign in with World ID. Created: ${new Date().toISOString()}`, - Tags: [{ TagKey: "app", TagValue: "developer-portal" }], - }), - ); - - const keyId = KeyMetadata?.KeyId; - const createdAt = KeyMetadata?.CreationDate; - - if (keyId && createdAt) { - const { PublicKey } = await client.send( - new GetPublicKeyCommand({ KeyId: keyId }), - ); - - if (PublicKey) { - const publicKey = `-----BEGIN PUBLIC KEY----- -${Buffer.from(PublicKey).toString("base64")} ------END PUBLIC KEY-----`; - - return { keyId, publicKey, createdAt }; - } - } - } catch (error) { - logger.error("Error creating key.", { error }); - } -}; - export const getKMSKeyStatus = async (client: KMSClient, keyId: string) => { try { const { KeyMetadata } = await client.send( diff --git a/web/lib/types.ts b/web/lib/types.ts index df818d000..3a4effcb0 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -1,10 +1,3 @@ -export enum OIDCFlowType { - AuthorizationCode = "authorization_code", - Implicit = "implicit", - Hybrid = "hybrid", - Token = "token", -} - import { InsertMembershipMutation } from "@/api/create-team/graphql/insert-membership.generated"; /** * This file contains the main types for both the frontend and backend. @@ -63,13 +56,6 @@ export type ActionStatsModel = Array<{ total_cumulative: number; }>; -export enum OIDCResponseType { - Code = "code", // authorization code - JWT = "jwt", // implicit flow - IdToken = "id_token", - Token = "token", -} - export interface IInternalError { message: string; code: string; From 0a3d4907e06cca58ba1093b9c4901e4c921203f3 Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:11:33 -0800 Subject: [PATCH 4/6] cleanup mocks --- web/tests/api/__mocks__/kms.mock.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/web/tests/api/__mocks__/kms.mock.ts b/web/tests/api/__mocks__/kms.mock.ts index e4bdb414d..072f83c3d 100644 --- a/web/tests/api/__mocks__/kms.mock.ts +++ b/web/tests/api/__mocks__/kms.mock.ts @@ -1,5 +1,5 @@ -import { createPrivateKey, createPublicKey, createSign } from "crypto"; -import { privateJwk, publicJwk } from "./jwk"; +import { createPrivateKey, createSign } from "crypto"; +import { privateJwk } from "./jwk"; module.exports = { getKMSClient: jest.fn().mockImplementation(() => ({ @@ -16,13 +16,5 @@ module.exports = { }; }), })), - createKMSKey: jest.fn().mockImplementation(async () => { - const key = createPublicKey({ format: "jwk", key: publicJwk }); - const pemKey = key.export({ type: "pkcs1", format: "pem" }); - return { - keyId: "test-kms-key-id", - publicKey: pemKey, - }; - }), scheduleKeyDeletion: jest.fn(), }; From 4aa29fb3799e2c7d039ad14249f120c86636660b Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Fri, 13 Feb 2026 03:39:00 -0800 Subject: [PATCH 5/6] chore: restore backend OIDC infrastructure Backend removal will be done in separate PR. Restored: - OIDC API endpoints (api/v1/oidc/*) - JWKS endpoints and helpers - Hasura reset-client-secret action - OIDC helper functions and types - Backend tests - Hasura metadata The complete backend+frontend removal is saved in branch: 0x1/FE/DEV-2618/Remove-WorldID-Backend --- hasura/metadata/actions.graphql | 11 + hasura/metadata/actions.yaml | 12 + hasura/metadata/cron_triggers.yaml | 14 + .../auxiliary/auxiliary-endpoints.spec.ts | 21 + web/.env.example | 3 + web/api/_delete-jwks/index.ts | 21 + .../graphql/get-membership.generated.ts | 71 ++ .../graphql/get-membership.graphql | 11 + .../graphql/update-secret.generated.ts | 68 ++ .../graphql/update-secret.graphql | 8 + web/api/hasura/reset-client-secret/index.ts | 104 +++ .../graphql/delete-expired-jwks.generated.ts | 71 ++ .../jwks/graphql/delete-expired-jwks.gql | 8 + ...tive-jwks-by-expiration-query.generated.ts | 71 ++ .../fetch-active-jwks-by-expiration-query.gql | 10 + .../jwks/graphql/insert-jwk.generated.ts | 79 ++ web/api/helpers/jwks/graphql/insert-jwk.gql | 17 + .../jwks/graphql/retrieve-jwk.generated.ts | 67 ++ web/api/helpers/jwks/graphql/retrieve-jwk.gql | 7 + web/api/helpers/jwks/index.ts | 204 +++++ web/api/helpers/jwts.ts | 110 +++ web/api/helpers/kms.ts | 88 ++ .../fetch-app-secret-query.generated.ts | 75 ++ .../oidc/graphql/fetch-app-secret-query.gql | 15 + .../oidc/graphql/fetch-oidc-app.generated.ts | 88 ++ .../helpers/oidc/graphql/fetch-oidc-app.gql | 21 + .../graphql/insert-auth-code.generated.ts | 99 +++ .../helpers/oidc/graphql/insert-auth-code.gql | 30 + web/api/helpers/oidc/index.ts | 240 ++++++ web/api/v1/jwks/graphql/get-jwks.generated.ts | 65 ++ web/api/v1/jwks/graphql/get-jwks.graphql | 7 + web/api/v1/jwks/index.ts | 28 + .../graphql/fetch-nullifier.generated.ts | 60 ++ .../authorize/graphql/fetch-nullifier.graphql | 5 + .../graphql/upsert-nullifier.generated.ts | 70 ++ .../graphql/upsert-nullifier.graphql | 9 + web/api/v1/oidc/authorize/index.ts | 439 ++++++++++ web/api/v1/oidc/introspect/index.ts | 75 ++ web/api/v1/oidc/openid-configuration/index.ts | 38 + .../graphql/delete-auth-code.generated.ts | 95 +++ .../token/graphql/delete-auth-code.graphql | 24 + .../graphql/fetch-redirect-count.generated.ts | 61 ++ .../graphql/fetch-redirect-count.graphql | 5 + web/api/v1/oidc/token/index.ts | 244 ++++++ web/api/v1/oidc/userinfo/index.ts | 109 +++ web/api/v1/oidc/validate/index.ts | 69 ++ .../api/hasura/reset-client-secret/route.ts | 1 + web/app/api/v1/jwks/route.ts | 1 + web/app/api/v1/oidc/authorize/route.ts | 1 + web/app/api/v1/oidc/introspect/route.ts | 1 + .../api/v1/oidc/openid-configuration/route.ts | 1 + web/app/api/v1/oidc/token/route.ts | 1 + web/app/api/v1/oidc/userinfo/route.ts | 1 + web/app/api/v1/oidc/validate/route.ts | 1 + web/lib/constants.ts | 6 + web/lib/types.ts | 14 + web/next.config.mjs | 4 + web/tests/api/__mocks__/jwks.mock.ts | 10 + web/tests/api/__mocks__/kms.mock.ts | 13 +- web/tests/api/delete-jwks.test.ts | 112 +++ web/tests/api/v1/oidc/authorize.test.ts | 375 +++++++++ web/tests/api/v1/oidc/userinfo.test.ts | 46 ++ web/tests/api/v1/oidc/validate.test.ts | 108 +++ web/tests/integration/jwks.test.ts | 92 +++ web/tests/integration/oidc/authorize.test.ts | 151 ++++ web/tests/integration/oidc/token.test.ts | 749 ++++++++++++++++++ web/tests/unit/check-flow-type.test.ts | 59 ++ 67 files changed, 4792 insertions(+), 2 deletions(-) create mode 100644 web/api/_delete-jwks/index.ts create mode 100644 web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts create mode 100644 web/api/hasura/reset-client-secret/graphql/get-membership.graphql create mode 100644 web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts create mode 100644 web/api/hasura/reset-client-secret/graphql/update-secret.graphql create mode 100644 web/api/hasura/reset-client-secret/index.ts create mode 100644 web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts create mode 100644 web/api/helpers/jwks/graphql/delete-expired-jwks.gql create mode 100644 web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts create mode 100644 web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql create mode 100644 web/api/helpers/jwks/graphql/insert-jwk.generated.ts create mode 100644 web/api/helpers/jwks/graphql/insert-jwk.gql create mode 100644 web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts create mode 100644 web/api/helpers/jwks/graphql/retrieve-jwk.gql create mode 100644 web/api/helpers/jwks/index.ts create mode 100644 web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts create mode 100644 web/api/helpers/oidc/graphql/fetch-app-secret-query.gql create mode 100644 web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts create mode 100644 web/api/helpers/oidc/graphql/fetch-oidc-app.gql create mode 100644 web/api/helpers/oidc/graphql/insert-auth-code.generated.ts create mode 100644 web/api/helpers/oidc/graphql/insert-auth-code.gql create mode 100644 web/api/helpers/oidc/index.ts create mode 100644 web/api/v1/jwks/graphql/get-jwks.generated.ts create mode 100644 web/api/v1/jwks/graphql/get-jwks.graphql create mode 100644 web/api/v1/jwks/index.ts create mode 100644 web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts create mode 100644 web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql create mode 100644 web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts create mode 100644 web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql create mode 100644 web/api/v1/oidc/authorize/index.ts create mode 100644 web/api/v1/oidc/introspect/index.ts create mode 100644 web/api/v1/oidc/openid-configuration/index.ts create mode 100644 web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts create mode 100644 web/api/v1/oidc/token/graphql/delete-auth-code.graphql create mode 100644 web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts create mode 100644 web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql create mode 100644 web/api/v1/oidc/token/index.ts create mode 100644 web/api/v1/oidc/userinfo/index.ts create mode 100644 web/api/v1/oidc/validate/index.ts create mode 100644 web/app/api/hasura/reset-client-secret/route.ts create mode 100644 web/app/api/v1/jwks/route.ts create mode 100644 web/app/api/v1/oidc/authorize/route.ts create mode 100644 web/app/api/v1/oidc/introspect/route.ts create mode 100644 web/app/api/v1/oidc/openid-configuration/route.ts create mode 100644 web/app/api/v1/oidc/token/route.ts create mode 100644 web/app/api/v1/oidc/userinfo/route.ts create mode 100644 web/app/api/v1/oidc/validate/route.ts create mode 100644 web/tests/api/__mocks__/jwks.mock.ts create mode 100644 web/tests/api/delete-jwks.test.ts create mode 100644 web/tests/api/v1/oidc/authorize.test.ts create mode 100644 web/tests/api/v1/oidc/userinfo.test.ts create mode 100644 web/tests/api/v1/oidc/validate.test.ts create mode 100644 web/tests/integration/jwks.test.ts create mode 100644 web/tests/integration/oidc/authorize.test.ts create mode 100644 web/tests/integration/oidc/token.test.ts create mode 100644 web/tests/unit/check-flow-type.test.ts diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 09fa6fd47..011a789df 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -88,6 +88,13 @@ type Mutation { ): ResetAPIOutput } +type Mutation { + reset_client_secret( + app_id: String! + team_id: String! + ): ResetClientOutput +} + type Mutation { rotate_signer_key( app_id: String! @@ -248,6 +255,10 @@ input ChangeAppReportStatusInput { updates: [ChangeAppReportStatusUpdate!]! } +type ResetClientOutput { + client_secret: String! +} + type ResetAPIOutput { api_key: String! } diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index 0850867e9..c6db5969d 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -159,6 +159,17 @@ actions: permissions: - role: user comment: Reset the given API key for the developer portal + - name: reset_client_secret + definition: + kind: synchronous + handler: '{{NEXT_API_URL}}/hasura/reset-client-secret' + headers: + - name: Authorization + value_from_env: INTERNAL_ENDPOINTS_SECRET + permissions: + - role: api_key + - role: user + comment: Reset the client secret for a Sign in with World ID application - name: rotate_signer_key definition: kind: synchronous @@ -380,6 +391,7 @@ custom_types: - name: ChangeAppReportStatusUpdate - name: ChangeAppReportStatusInput objects: + - name: ResetClientOutput - name: ResetAPIOutput - name: InviteTeamMembersOutput - name: PresignedPost diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index 5ea3eb36b..07132985a 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -26,6 +26,20 @@ - name: Authorization value_from_env: INTERNAL_ENDPOINTS_SECRET comment: "" +- name: Delete expired jwks + webhook: '{{NEXT_API_URL}}/_delete-jwks' + schedule: 0 * * * * + include_in_metadata: true + payload: {} + retry_conf: + num_retries: 1 + retry_interval_seconds: 10 + timeout_seconds: 60 + tolerance_seconds: 21600 + headers: + - name: Authorization + value_from_env: INTERNAL_ENDPOINTS_SECRET + comment: Schedules all expired JWKS for deletion by KMS - name: Rollup app stats webhook: '{{NEXT_API_URL}}/_rollup-app-stats' schedule: 0 * * * * diff --git a/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts b/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts index 73e457410..b92da7fb0 100644 --- a/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts +++ b/tests/api/specs/auxiliary/auxiliary-endpoints.spec.ts @@ -47,6 +47,27 @@ describe("Auxiliary API Endpoints", () => { }); }); + describe("POST /api/_delete-jwks", () => { + it("Delete Expired JWKs With Authorization", async () => { + const response = await axios.post( + `${INTERNAL_API_URL}/api/_delete-jwks`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }, + ); + + expect( + response.status, + `Delete expired JWKs request resolved with a wrong code:\n${JSON.stringify(response.data, null, 2)}`, + ).toBe(204); // This endpoint returns 204 No Content + expect(response.data).toBe(""); // Empty response body + }); + }); + describe("POST /api/_gen-external-nullifier", () => { let testTeamId: string | undefined; let testAppId: string | undefined; diff --git a/web/.env.example b/web/.env.example index db756de07..4f77ac9ee 100644 --- a/web/.env.example +++ b/web/.env.example @@ -40,6 +40,9 @@ SENDGRID_API_KEY=your_sendgrid_api_key SENDGRID_EMAIL_FROM=your_email@example.com SENDGRID_TEAM_INVITE_TEMPLATE_ID=d-5cbc349e16344604813f74c1f8b0bbdc +# Sign in with World ID +SIGN_IN_WITH_WORLD_ID_APP_ID= + # Ironclad IRONCLAD_ACCESS_ID=your_ironclad_access_id IRONCLAD_GROUP_KEY=your_ironclad_group_key diff --git a/web/api/_delete-jwks/index.ts b/web/api/_delete-jwks/index.ts new file mode 100644 index 000000000..db0cba148 --- /dev/null +++ b/web/api/_delete-jwks/index.ts @@ -0,0 +1,21 @@ +import { _deleteExpiredJWKs } from "@/api/helpers/jwks"; +import { protectInternalEndpoint } from "@/api/helpers/utils"; +import { logger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Deletes expired JWKs + */ +export async function POST(request: NextRequest) { + const { isAuthenticated, errorResponse } = protectInternalEndpoint(request); + if (!isAuthenticated) { + return errorResponse; + } + logger.info("Starting deletion of expired jwks."); + + const response = await _deleteExpiredJWKs(); + + logger.info(`Deleted ${response} expired jwks.`); + + return new NextResponse(null, { status: 204 }); +} diff --git a/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts b/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts new file mode 100644 index 000000000..e24bba16f --- /dev/null +++ b/web/api/hasura/reset-client-secret/graphql/get-membership.generated.ts @@ -0,0 +1,71 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type GetMembershipQueryVariables = Types.Exact<{ + team_id: Types.Scalars["String"]["input"]; + user_id: Types.Scalars["String"]["input"]; + app_id: Types.Scalars["String"]["input"]; +}>; + +export type GetMembershipQuery = { + __typename?: "query_root"; + team: Array<{ __typename?: "team"; id: string }>; +}; + +export const GetMembershipDocument = gql` + query GetMembership($team_id: String!, $user_id: String!, $app_id: String!) { + team( + where: { + id: { _eq: $team_id } + memberships: { + user_id: { _eq: $user_id } + role: { _in: [ADMIN, OWNER] } + } + apps: { id: { _eq: $app_id } } + } + ) { + id + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + GetMembership( + variables: GetMembershipQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(GetMembershipDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "GetMembership", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/hasura/reset-client-secret/graphql/get-membership.graphql b/web/api/hasura/reset-client-secret/graphql/get-membership.graphql new file mode 100644 index 000000000..bd46dc78f --- /dev/null +++ b/web/api/hasura/reset-client-secret/graphql/get-membership.graphql @@ -0,0 +1,11 @@ +query GetMembership($team_id: String!, $user_id: String!, $app_id: String!) { + team( + where: { + id: { _eq: $team_id } + memberships: { user_id: { _eq: $user_id }, role: { _in: [ADMIN, OWNER] } } + apps: { id: { _eq: $app_id } } + } + ) { + id + } +} diff --git a/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts b/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts new file mode 100644 index 000000000..c6bf48487 --- /dev/null +++ b/web/api/hasura/reset-client-secret/graphql/update-secret.generated.ts @@ -0,0 +1,68 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type UpdateSecretMutationVariables = Types.Exact<{ + app_id: Types.Scalars["String"]["input"]; + hashed_secret: Types.Scalars["String"]["input"]; +}>; + +export type UpdateSecretMutation = { + __typename?: "mutation_root"; + update_action?: { + __typename?: "action_mutation_response"; + affected_rows: number; + } | null; +}; + +export const UpdateSecretDocument = gql` + mutation UpdateSecret($app_id: String!, $hashed_secret: String!) { + update_action( + where: { app_id: { _eq: $app_id }, action: { _eq: "" } } + _set: { client_secret: $hashed_secret } + ) { + affected_rows + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + UpdateSecret( + variables: UpdateSecretMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + UpdateSecretDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "UpdateSecret", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/hasura/reset-client-secret/graphql/update-secret.graphql b/web/api/hasura/reset-client-secret/graphql/update-secret.graphql new file mode 100644 index 000000000..2e27c5c13 --- /dev/null +++ b/web/api/hasura/reset-client-secret/graphql/update-secret.graphql @@ -0,0 +1,8 @@ +mutation UpdateSecret($app_id: String!, $hashed_secret: String!) { + update_action( + where: { app_id: { _eq: $app_id }, action: { _eq: "" } } + _set: { client_secret: $hashed_secret } + ) { + affected_rows + } +} diff --git a/web/api/hasura/reset-client-secret/index.ts b/web/api/hasura/reset-client-secret/index.ts new file mode 100644 index 000000000..12caab52b --- /dev/null +++ b/web/api/hasura/reset-client-secret/index.ts @@ -0,0 +1,104 @@ +import { errorHasuraQuery } from "@/api/helpers/errors"; +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { + generateHashedSecret, + protectInternalEndpoint, +} from "@/api/helpers/utils"; +import { logger } from "@/lib/logger"; +import { NextRequest, NextResponse } from "next/server"; +import { getSdk as getMembershipSdk } from "./graphql/get-membership.generated"; +import { getSdk as updateSecretSDK } from "./graphql/update-secret.generated"; + +export const POST = async (req: NextRequest) => { + const { isAuthenticated, errorResponse } = protectInternalEndpoint(req); + if (!isAuthenticated) { + return errorResponse; + } + const body = await req.json(); + + if (body?.action.name !== "reset_client_secret") { + return errorHasuraQuery({ + req, + detail: "Invalid action.", + code: "invalid_action", + }); + } + + if (body.session_variables["x-hasura-role"] === "admin") { + logger.error("Admin not allowed to run _reset-client-client-secret"), + { role: body.session_variables["x-hasura-role"] }; + return errorHasuraQuery({ + req, + detail: "Admin is not allowed to run this query.", + code: "admin_not_allowed", + }); + } + + const userId = body.session_variables["x-hasura-user-id"]; + if (!userId) { + return errorHasuraQuery({ + req, + detail: "userId must be set.", + code: "required", + }); + } + + const team_id = body.input.team_id; + if (!team_id) { + return errorHasuraQuery({ + req, + detail: "team_id must be set.", + code: "required", + }); + } + + const app_id = body.input.app_id; + if (!app_id) { + return errorHasuraQuery({ + req, + detail: "`app_id` is a required input.", + code: "required", + team_id, + }); + } + + const client = await getAPIServiceGraphqlClient(); + + // ANCHOR: Make sure the user can perform this client reset + const { team: teamMembershipQuery } = await getMembershipSdk( + client, + ).GetMembership({ + user_id: userId, + team_id, + app_id, + }); + + if (!teamMembershipQuery || !teamMembershipQuery.length) { + return errorHasuraQuery({ + req, + detail: "Insufficient Permissions", + code: "insufficient_permissions", + team_id, + app_id, + }); + } + + const { secret: client_secret, hashed_secret } = generateHashedSecret(app_id); + const { update_action: updateResponse } = await updateSecretSDK( + client, + ).UpdateSecret({ + app_id: app_id, + hashed_secret, + }); + + if (!updateResponse?.affected_rows) { + return errorHasuraQuery({ + req, + detail: "Failed to reset the client secret.", + code: "update_failed", + team_id, + app_id, + }); + } + return NextResponse.json({ client_secret }); +}; diff --git a/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts b/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts new file mode 100644 index 000000000..ca00f02b8 --- /dev/null +++ b/web/api/helpers/jwks/graphql/delete-expired-jwks.generated.ts @@ -0,0 +1,71 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type DeleteExpiredJwKsMutationVariables = Types.Exact<{ + expired_by?: Types.InputMaybe; +}>; + +export type DeleteExpiredJwKsMutation = { + __typename?: "mutation_root"; + delete_jwks?: { + __typename?: "jwks_mutation_response"; + returning: Array<{ + __typename?: "jwks"; + id: string; + kms_id?: string | null; + }>; + } | null; +}; + +export const DeleteExpiredJwKsDocument = gql` + mutation DeleteExpiredJWKs($expired_by: timestamptz = "") { + delete_jwks(where: { expires_at: { _lte: $expired_by } }) { + returning { + id + kms_id + } + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + DeleteExpiredJWKs( + variables?: DeleteExpiredJwKsMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + DeleteExpiredJwKsDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "DeleteExpiredJWKs", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/delete-expired-jwks.gql b/web/api/helpers/jwks/graphql/delete-expired-jwks.gql new file mode 100644 index 000000000..e9a76de7c --- /dev/null +++ b/web/api/helpers/jwks/graphql/delete-expired-jwks.gql @@ -0,0 +1,8 @@ +mutation DeleteExpiredJWKs($expired_by: timestamptz = "") { + delete_jwks(where: { expires_at: { _lte: $expired_by } }) { + returning { + id + kms_id + } + } +} diff --git a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts new file mode 100644 index 000000000..921bbbb37 --- /dev/null +++ b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.generated.ts @@ -0,0 +1,71 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type FetchActiveJwKsByExpirationQueryVariables = Types.Exact<{ + expires_at: Types.Scalars["timestamptz"]["input"]; +}>; + +export type FetchActiveJwKsByExpirationQuery = { + __typename?: "query_root"; + jwks: Array<{ + __typename?: "jwks"; + id: string; + kms_id?: string | null; + expires_at: string; + }>; +}; + +export const FetchActiveJwKsByExpirationDocument = gql` + query FetchActiveJWKsByExpiration($expires_at: timestamptz!) { + jwks( + where: { expires_at: { _gt: $expires_at } } + order_by: { expires_at: desc } + ) { + id + kms_id + expires_at + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + FetchActiveJWKsByExpiration( + variables: FetchActiveJwKsByExpirationQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + FetchActiveJwKsByExpirationDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "FetchActiveJWKsByExpiration", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql new file mode 100644 index 000000000..0df06c622 --- /dev/null +++ b/web/api/helpers/jwks/graphql/fetch-active-jwks-by-expiration-query.gql @@ -0,0 +1,10 @@ +query FetchActiveJWKsByExpiration($expires_at: timestamptz!) { + jwks( + where: { expires_at: { _gt: $expires_at } } + order_by: { expires_at: desc } + ) { + id + kms_id + expires_at + } +} diff --git a/web/api/helpers/jwks/graphql/insert-jwk.generated.ts b/web/api/helpers/jwks/graphql/insert-jwk.generated.ts new file mode 100644 index 000000000..57904ae79 --- /dev/null +++ b/web/api/helpers/jwks/graphql/insert-jwk.generated.ts @@ -0,0 +1,79 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type InsertJwkMutationVariables = Types.Exact<{ + expires_at: Types.Scalars["timestamptz"]["input"]; + public_jwk: Types.Scalars["jsonb"]["input"]; + kms_id: Types.Scalars["String"]["input"]; +}>; + +export type InsertJwkMutation = { + __typename?: "mutation_root"; + insert_jwks_one?: { + __typename?: "jwks"; + id: string; + kms_id?: string | null; + expires_at: string; + } | null; +}; + +export const InsertJwkDocument = gql` + mutation InsertJWK( + $expires_at: timestamptz! + $public_jwk: jsonb! + $kms_id: String! + ) { + insert_jwks_one( + object: { + expires_at: $expires_at + kms_id: $kms_id + public_jwk: $public_jwk + } + ) { + id + kms_id + expires_at + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + InsertJWK( + variables: InsertJwkMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(InsertJwkDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "InsertJWK", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/insert-jwk.gql b/web/api/helpers/jwks/graphql/insert-jwk.gql new file mode 100644 index 000000000..3edab5578 --- /dev/null +++ b/web/api/helpers/jwks/graphql/insert-jwk.gql @@ -0,0 +1,17 @@ +mutation InsertJWK( + $expires_at: timestamptz! + $public_jwk: jsonb! + $kms_id: String! +) { + insert_jwks_one( + object: { + expires_at: $expires_at + kms_id: $kms_id + public_jwk: $public_jwk + } + ) { + id + kms_id + expires_at + } +} diff --git a/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts b/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts new file mode 100644 index 000000000..5c3474f1b --- /dev/null +++ b/web/api/helpers/jwks/graphql/retrieve-jwk.generated.ts @@ -0,0 +1,67 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type RetrieveJwkQueryVariables = Types.Exact<{ + kid: Types.Scalars["String"]["input"]; +}>; + +export type RetrieveJwkQuery = { + __typename?: "query_root"; + jwks: Array<{ + __typename?: "jwks"; + id: string; + kms_id?: string | null; + public_jwk: any; + }>; +}; + +export const RetrieveJwkDocument = gql` + query RetrieveJWK($kid: String!) { + jwks(limit: 1, where: { id: { _eq: $kid } }) { + id + kms_id + public_jwk + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + RetrieveJWK( + variables: RetrieveJwkQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(RetrieveJwkDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "RetrieveJWK", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/jwks/graphql/retrieve-jwk.gql b/web/api/helpers/jwks/graphql/retrieve-jwk.gql new file mode 100644 index 000000000..a2e4b7e4f --- /dev/null +++ b/web/api/helpers/jwks/graphql/retrieve-jwk.gql @@ -0,0 +1,7 @@ +query RetrieveJWK($kid: String!) { + jwks(limit: 1, where: { id: { _eq: $kid } }) { + id + kms_id + public_jwk + } +} diff --git a/web/api/helpers/jwks/index.ts b/web/api/helpers/jwks/index.ts new file mode 100644 index 000000000..94a4ffc84 --- /dev/null +++ b/web/api/helpers/jwks/index.ts @@ -0,0 +1,204 @@ +import "server-only"; + +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { + createKMSKey, + getKMSClient, + scheduleKeyDeletion, +} from "@/api/helpers/kms"; +import { JWK_TIME_TO_LIVE, JWK_TTL_USABLE } from "@/lib/constants"; +import { logger } from "@/lib/logger"; +import { createPublicKey } from "crypto"; +import dayjs from "dayjs"; + +import { + getSdk as getRetrieveJWKSdk, + RetrieveJwkQuery, +} from "./graphql/retrieve-jwk.generated"; + +import { + FetchActiveJwKsByExpirationQuery, + getSdk as fetchActiveJWKsByExpirationSdk, +} from "./graphql/fetch-active-jwks-by-expiration-query.generated"; + +import { + InsertJwkMutation, + getSdk as insertJWKSdk, +} from "./graphql/insert-jwk.generated"; + +import { + DeleteExpiredJwKsMutation, + getSdk as deleteExpiredJWKsSdk, +} from "./graphql/delete-expired-jwks.generated"; + +export type CreateJWKResult = { + keyId: string; + publicJwk: JsonWebKey; + createdAt: Date; +}; +/** + * Get the public JWK for a given kid + * @param kid + * @returns + */ +export const retrieveJWK = async (kid: string) => { + const client = await getAPIServiceGraphqlClient(); + + let jwks: RetrieveJwkQuery["jwks"] | null = null; + + try { + const data = await getRetrieveJWKSdk(client).RetrieveJWK({ + kid, + }); + + jwks = data.jwks; + } catch (error) { + logger.error("Error retrieving JWK.", { error }); + + throw error; + } + + if (!jwks?.length) { + throw new Error("JWK not found."); + } + + const { id, kms_id, public_jwk } = jwks[0]; + return { kid: id, kms_id, public_jwk }; +}; + +/** + * Generates an RS256 asymmetric key pair in JWK format + * @returns + */ +export const generateJWK = async (): Promise => { + const client = await getKMSClient(); + + if (client) { + const result = await createKMSKey(client, "RSA_2048"); + if (result?.keyId && result?.publicKey) { + const publicJwk = createPublicKey(result.publicKey).export({ + format: "jwk", + }); + + return { keyId: result.keyId, publicJwk, createdAt: result.createdAt }; + } else { + throw new Error("Unable to create KMS key."); + } + } else { + throw new Error("KMS client not found."); + } +}; + +/** + * Generate new JWK. Generates a new KMS key and stores the public key in the database. + * @param alg + * @returns + */ +export const createAndStoreJWK = async () => { + const key = await generateJWK(); + const expiresAt = dayjs(key.createdAt).add(JWK_TIME_TO_LIVE, "day"); + + const client = await getAPIServiceGraphqlClient(); + + let insertResult: InsertJwkMutation["insert_jwks_one"] | null = null; + + try { + const { insert_jwks_one } = await insertJWKSdk(client).InsertJWK({ + expires_at: expiresAt.toISOString(), + kms_id: key.keyId, + public_jwk: key.publicJwk, + }); + + insertResult = insert_jwks_one; + } catch (error) { + logger.error("Error inserting JWK.", { error }); + throw error; + } + + if (insertResult) { + return insertResult; + } + + logger.error("Unable to create new JWK.", { insertResult }); + throw new Error("Unable to create new JWK."); +}; + +/** + * Fetches an active JWK to sign requests, and otherwise rotates the key + * @param alg + * @returns + */ +export const fetchActiveJWK = async () => { + const apiClient = await getAPIServiceGraphqlClient(); + + let jwks: FetchActiveJwKsByExpirationQuery["jwks"] | null = null; + + try { + const data = await fetchActiveJWKsByExpirationSdk( + apiClient, + ).FetchActiveJWKsByExpiration({ + expires_at: new Date().toISOString(), + }); + + jwks = data.jwks; + } catch (error) { + logger.error("Error fetching active JWK.", { error }); + throw error; + } + + // JWK is still active + if (jwks?.length) { + const { id, kms_id, expires_at } = jwks[0]; + + // Only return JWK if it's not expiring in the next few days + const now = dayjs(); + const expires = dayjs(expires_at); + if (expires.diff(now, "day") > JWK_TTL_USABLE) { + return { kid: id, kms_id }; + } + } + + // JWK is expired or expiring soon, rotate the key + const jwk = await createAndStoreJWK(); + return { kid: jwk.id, kms_id: jwk.kms_id }; +}; + +/** + * Delete all expired JWKs from the database + * @returns + */ +export const _deleteExpiredJWKs = async () => { + const apiClient = await getAPIServiceGraphqlClient(); + + let deleteResult: DeleteExpiredJwKsMutation["delete_jwks"] | null = null; + + try { + const data = await deleteExpiredJWKsSdk(apiClient).DeleteExpiredJWKs({ + expired_by: new Date(Date.now() - 20 * 60 * 1000).toISOString(), // 20 minutes ago + }); + + deleteResult = data.delete_jwks; + } catch (error) { + logger.error("Error deleting expired JWKs.", { error }); + throw error; + } + + if (deleteResult?.returning) { + // Schedule each KMS key for deletion + const kmsClient = await getKMSClient(); + + if (kmsClient) { + for (const key of deleteResult.returning) { + if (!key.kms_id) { + logger.error("KMS ID not found for JWK.", { key }); + + continue; + } + + await scheduleKeyDeletion(kmsClient, key.kms_id); + } + } + + return deleteResult.returning.length; + } +}; diff --git a/web/api/helpers/jwts.ts b/web/api/helpers/jwts.ts index 57b6fdc41..d8adf775e 100644 --- a/web/api/helpers/jwts.ts +++ b/web/api/helpers/jwts.ts @@ -2,9 +2,15 @@ import "server-only"; /** * Contains all backend utilities related to JWTs. + * * OIDC tokens * * Hasura authentication * * Developer Portal authentication */ +import { retrieveJWK } from "@/api/helpers/jwks"; +import { getKMSClient, signJWTWithKMSKey } from "@/api/helpers/kms"; +import { OIDCScopes } from "@/api/helpers/oidc"; +import { VerificationLevel } from "@worldcoin/idkit-core"; +import { randomUUID } from "crypto"; import dayjs from "dayjs"; import * as jose from "jose"; @@ -205,3 +211,107 @@ export const verifySignUpJWT = async (token: string) => { } return { sub }; }; + +// ANCHOR: -----------------OIDC JWTs-------------------------- + +const formatOIDCDateTime = (date: Date | dayjs.Dayjs): number => { + return dayjs(date).unix(); +}; + +interface IVerificationJWT { + kid: string; + kms_id: string; + nonce?: string; + nullifier_hash: string; + app_id: string; + verification_level: VerificationLevel; + scope: OIDCScopes[]; +} + +/** + * Generates a JWT that can be used to verify a proof (used for Sign in with World ID) + * @returns + */ +export const generateOIDCJWT = async ({ + app_id, + nonce, + nullifier_hash, + kid, + verification_level, + scope, +}: IVerificationJWT): Promise => { + const payload = { + iss: JWT_ISSUER, + sub: nullifier_hash, + jti: randomUUID(), + iat: formatOIDCDateTime(new Date()), + exp: formatOIDCDateTime(dayjs().add(1, "hour")), + aud: app_id, + scope: scope.join(" "), + // NOTE: DEPRECATED, will be removed in future versions + "https://id.worldcoin.org/beta": { + likely_human: + verification_level === VerificationLevel.Orb ? "strong" : "weak", + credential_type: verification_level, + warning: + "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", + }, + "https://id.worldcoin.org/v1": { + verification_level, + }, + } as Record; + + if (nonce) { + payload.nonce = nonce; + } + + if (scope.includes(OIDCScopes.Email)) { + payload.email = `${nullifier_hash}@id.worldcoin.org`; + } + + if (scope.includes(OIDCScopes.Profile)) { + payload.name = "World ID User"; + payload.given_name = "World ID"; + payload.family_name = "User"; + } + + // Sign the JWT with a KMS managed key + const client = await getKMSClient(); + const header = { + alg: "RS256", + typ: "JWT", + kid, + }; + + if (client) { + const token = await signJWTWithKMSKey(client, header, payload); + if (token) return token; + } + throw new Error("Failed to sign JWT from KMS."); +}; + +export const verifyOIDCJWT = async ( + token: string, +): Promise => { + const { kid } = jose.decodeProtectedHeader(token); + + if (!kid) { + throw new Error("JWT is invalid. Does not contain a `kid` claim."); + } + + const { public_jwk } = await retrieveJWK(kid); + + if (!public_jwk) { + throw new Error("Key for this JWT is invalid."); + } + + const { payload } = await jose.jwtVerify( + token, + await jose.importJWK(public_jwk, "RS256"), + { + issuer: JWT_ISSUER, + }, + ); + + return payload; +}; diff --git a/web/api/helpers/kms.ts b/web/api/helpers/kms.ts index d2fe83125..75a1ee7c6 100644 --- a/web/api/helpers/kms.ts +++ b/web/api/helpers/kms.ts @@ -4,12 +4,26 @@ import "server-only"; * Contains all functions for interacting with Amazon KMS */ +import { retrieveJWK } from "@/api/helpers/jwks"; import { logger } from "@/lib/logger"; import { + CreateKeyCommand, DescribeKeyCommand, + GetPublicKeyCommand, KMSClient, + KeySpec, ScheduleKeyDeletionCommand, + SignCommand, } from "@aws-sdk/client-kms"; +import { base64url } from "jose"; + +export type CreateKeyResult = + | { + keyId: string; + publicKey: string; + createdAt: Date; + } + | undefined; export const getKMSClient = async (region?: string) => { return new KMSClient({ @@ -17,6 +31,41 @@ export const getKMSClient = async (region?: string) => { }); }; +export const createKMSKey = async ( + client: KMSClient, + alg: KeySpec, +): Promise => { + try { + const { KeyMetadata } = await client.send( + new CreateKeyCommand({ + KeySpec: alg, + KeyUsage: "SIGN_VERIFY", + Description: `Developer Portal JWK for Sign in with World ID. Created: ${new Date().toISOString()}`, + Tags: [{ TagKey: "app", TagValue: "developer-portal" }], + }), + ); + + const keyId = KeyMetadata?.KeyId; + const createdAt = KeyMetadata?.CreationDate; + + if (keyId && createdAt) { + const { PublicKey } = await client.send( + new GetPublicKeyCommand({ KeyId: keyId }), + ); + + if (PublicKey) { + const publicKey = `-----BEGIN PUBLIC KEY----- +${Buffer.from(PublicKey).toString("base64")} +-----END PUBLIC KEY-----`; + + return { keyId, publicKey, createdAt }; + } + } + } catch (error) { + logger.error("Error creating key.", { error }); + } +}; + export const getKMSKeyStatus = async (client: KMSClient, keyId: string) => { try { const { KeyMetadata } = await client.send( @@ -30,6 +79,45 @@ export const getKMSKeyStatus = async (client: KMSClient, keyId: string) => { } }; +export const signJWTWithKMSKey = async ( + client: KMSClient, + header: Record, + payload: Record, +) => { + const encodedHeader = base64url.encode(JSON.stringify(header)); + const encodedPayload = base64url.encode(JSON.stringify(payload)); + const encodedHeaderPayload = `${encodedHeader}.${encodedPayload}`; + + try { + const { kms_id } = await retrieveJWK(header.kid); // NOTE: JWK is already verified to be active at this point + + if (!kms_id) { + throw new Error("KMS ID not found."); + } + + const response = await client.send( + new SignCommand({ + KeyId: kms_id, + Message: new Uint8Array(Buffer.from(encodedHeaderPayload)), + MessageType: "RAW", + SigningAlgorithm: "RSASSA_PKCS1_V1_5_SHA_256", + }), + ); + + if (response?.Signature) { + // See: https://www.rfc-editor.org/rfc/rfc7515#appendix-C + const encodedSignature = base64url + .encode(response.Signature) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + return `${encodedHeaderPayload}.${encodedSignature}`; + } + } catch (error) { + logger.error("Error signing JWT:", { error }); + } +}; + export const scheduleKeyDeletion = async (client: KMSClient, keyId: string) => { try { await client.send( diff --git a/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts b/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts new file mode 100644 index 000000000..fc9a5e3ab --- /dev/null +++ b/web/api/helpers/oidc/graphql/fetch-app-secret-query.generated.ts @@ -0,0 +1,75 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type FetchAppSecretQueryVariables = Types.Exact<{ + app_id: Types.Scalars["String"]["input"]; +}>; + +export type FetchAppSecretQuery = { + __typename?: "query_root"; + app: Array<{ + __typename?: "app"; + id: string; + actions: Array<{ __typename?: "action"; client_secret: string }>; + }>; +}; + +export const FetchAppSecretDocument = gql` + query FetchAppSecret($app_id: String!) { + app( + where: { + id: { _eq: $app_id } + status: { _eq: "active" } + is_archived: { _eq: false } + engine: { _eq: "cloud" } + } + ) { + id + actions(limit: 1, where: { action: { _eq: "" } }) { + client_secret + } + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + FetchAppSecret( + variables: FetchAppSecretQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + FetchAppSecretDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "FetchAppSecret", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql b/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql new file mode 100644 index 000000000..abda88e8e --- /dev/null +++ b/web/api/helpers/oidc/graphql/fetch-app-secret-query.gql @@ -0,0 +1,15 @@ +query FetchAppSecret($app_id: String!) { + app( + where: { + id: { _eq: $app_id } + status: { _eq: "active" } + is_archived: { _eq: false } + engine: { _eq: "cloud" } + } + ) { + id + actions(limit: 1, where: { action: { _eq: "" } }) { + client_secret + } + } +} diff --git a/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts b/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts new file mode 100644 index 000000000..272cd073b --- /dev/null +++ b/web/api/helpers/oidc/graphql/fetch-oidc-app.generated.ts @@ -0,0 +1,88 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type FetchOidcAppQueryVariables = Types.Exact<{ + app_id: Types.Scalars["String"]["input"]; + redirect_uri: Types.Scalars["String"]["input"]; +}>; + +export type FetchOidcAppQuery = { + __typename?: "query_root"; + app: Array<{ + __typename?: "app"; + id: string; + is_staging: boolean; + actions: Array<{ + __typename?: "action"; + id: string; + external_nullifier: string; + status: string; + redirects: Array<{ __typename?: "redirect"; redirect_uri: string }>; + }>; + }>; +}; + +export const FetchOidcAppDocument = gql` + query FetchOIDCApp($app_id: String!, $redirect_uri: String!) { + app( + where: { + id: { _eq: $app_id } + status: { _eq: "active" } + is_archived: { _eq: false } + engine: { _eq: "cloud" } + } + ) { + id + is_staging + actions(where: { action: { _eq: "" } }) { + id + external_nullifier + status + redirects(where: { redirect_uri: { _eq: $redirect_uri } }) { + redirect_uri + } + } + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + FetchOIDCApp( + variables: FetchOidcAppQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(FetchOidcAppDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "FetchOIDCApp", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/fetch-oidc-app.gql b/web/api/helpers/oidc/graphql/fetch-oidc-app.gql new file mode 100644 index 000000000..b82ab3ec6 --- /dev/null +++ b/web/api/helpers/oidc/graphql/fetch-oidc-app.gql @@ -0,0 +1,21 @@ +query FetchOIDCApp($app_id: String!, $redirect_uri: String!) { + app( + where: { + id: { _eq: $app_id } + status: { _eq: "active" } + is_archived: { _eq: false } + engine: { _eq: "cloud" } + } + ) { + id + is_staging + actions(where: { action: { _eq: "" } }) { + id + external_nullifier + status + redirects(where: { redirect_uri: { _eq: $redirect_uri } }) { + redirect_uri + } + } + } +} diff --git a/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts b/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts new file mode 100644 index 000000000..15f82d9a7 --- /dev/null +++ b/web/api/helpers/oidc/graphql/insert-auth-code.generated.ts @@ -0,0 +1,99 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type InsertAuthCodeMutationVariables = Types.Exact<{ + auth_code: Types.Scalars["String"]["input"]; + code_challenge?: Types.InputMaybe; + code_challenge_method?: Types.InputMaybe; + expires_at: Types.Scalars["timestamptz"]["input"]; + nullifier_hash: Types.Scalars["String"]["input"]; + app_id: Types.Scalars["String"]["input"]; + verification_level: Types.Scalars["String"]["input"]; + scope: Types.Scalars["jsonb"]["input"]; + nonce?: Types.InputMaybe; + redirect_uri?: Types.InputMaybe; +}>; + +export type InsertAuthCodeMutation = { + __typename?: "mutation_root"; + insert_auth_code_one?: { + __typename?: "auth_code"; + auth_code: string; + nonce?: string | null; + } | null; +}; + +export const InsertAuthCodeDocument = gql` + mutation InsertAuthCode( + $auth_code: String! + $code_challenge: String + $code_challenge_method: String + $expires_at: timestamptz! + $nullifier_hash: String! + $app_id: String! + $verification_level: String! + $scope: jsonb! + $nonce: String + $redirect_uri: String + ) { + insert_auth_code_one( + object: { + auth_code: $auth_code + code_challenge: $code_challenge + code_challenge_method: $code_challenge_method + expires_at: $expires_at + nullifier_hash: $nullifier_hash + app_id: $app_id + verification_level: $verification_level + scope: $scope + nonce: $nonce + redirect_uri: $redirect_uri + } + ) { + auth_code + nonce + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + InsertAuthCode( + variables: InsertAuthCodeMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + InsertAuthCodeDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "InsertAuthCode", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/helpers/oidc/graphql/insert-auth-code.gql b/web/api/helpers/oidc/graphql/insert-auth-code.gql new file mode 100644 index 000000000..2d1d5c890 --- /dev/null +++ b/web/api/helpers/oidc/graphql/insert-auth-code.gql @@ -0,0 +1,30 @@ +mutation InsertAuthCode( + $auth_code: String! + $code_challenge: String + $code_challenge_method: String + $expires_at: timestamptz! + $nullifier_hash: String! + $app_id: String! + $verification_level: String! + $scope: jsonb! + $nonce: String + $redirect_uri: String +) { + insert_auth_code_one( + object: { + auth_code: $auth_code + code_challenge: $code_challenge + code_challenge_method: $code_challenge_method + expires_at: $expires_at + nullifier_hash: $nullifier_hash + app_id: $app_id + verification_level: $verification_level + scope: $scope + nonce: $nonce + redirect_uri: $redirect_uri + } + ) { + auth_code + nonce + } +} diff --git a/web/api/helpers/oidc/index.ts b/web/api/helpers/oidc/index.ts new file mode 100644 index 000000000..6cc67a7e5 --- /dev/null +++ b/web/api/helpers/oidc/index.ts @@ -0,0 +1,240 @@ +import { verifyHashedSecret } from "@/api/helpers/utils"; +import { logger } from "@/lib/logger"; +import { IInternalError, OIDCFlowType, OIDCResponseType } from "@/lib/types"; +import { VerificationLevel } from "@worldcoin/idkit-core"; +import crypto from "crypto"; +import "server-only"; + +import { + FetchAppSecretQuery, + getSdk as FetchAppSecretQuerySdk, +} from "./graphql/fetch-app-secret-query.generated"; + +import { + getSdk as FetchOIDCAppSdk, + FetchOidcAppQuery, +} from "./graphql/fetch-oidc-app.generated"; + +import { getAPIServiceGraphqlClient } from "../graphql"; +import { + InsertAuthCodeMutation, + getSdk as InsertAuthCodeSdk, +} from "./graphql/insert-auth-code.generated"; + +export const OIDCResponseTypeMapping = { + code: OIDCResponseType.Code, + id_token: OIDCResponseType.JWT, + token: OIDCResponseType.JWT, +}; + +export enum OIDCScopes { + OpenID = "openid", + Email = "email", + Profile = "profile", +} + +export enum OIDCErrorCodes { + InvalidRequest = "invalid_request", // RFC6749 OAuth 2.0 (4.1.2.1) + UnsupportedResponseType = "unsupported_response_type", // RFC6749 OAuth 2.0 (4.1.2.1) + InvalidScope = "invalid_scope", // RFC6749 OAuth 2.0 (4.1.2.1) + InvalidRedirectURI = "invalid_redirect_uri", // Custom +} + +interface OIDCApp { + id: FetchOidcAppQuery["app"][number]["id"]; + is_staging: FetchOidcAppQuery["app"][number]["is_staging"]; + external_nullifier: FetchOidcAppQuery["app"][number]["actions"][number]["external_nullifier"]; + action_id: FetchOidcAppQuery["app"][number]["actions"][number]["id"]; + registered_redirect_uri?: FetchOidcAppQuery["app"][number]["actions"][number]["redirects"][number]["redirect_uri"]; +} + +export const fetchOIDCApp = async ( + app_id: string, + redirect_uri: string, +): Promise<{ app?: OIDCApp; error?: IInternalError }> => { + const client = await getAPIServiceGraphqlClient(); + + let data: FetchOidcAppQuery | null = null; + + try { + data = await FetchOIDCAppSdk(client).FetchOIDCApp({ + app_id, + redirect_uri, + }); + } catch (error) { + logger.error("fetchOIDCApp - Failed to fetch OIDC app.", { error }); + + return { + error: { + code: "internal_server_error", + message: "Failed to fetch OIDC app.", + statusCode: 500, + }, + }; + } + + if (data.app.length === 0) { + return { + error: { + code: "app_not_found", + message: "App not found or not active.", + statusCode: 404, + }, + }; + } + + const app = data.app[0]; + + if (!app.actions?.length || app.actions[0].status === "inactive") { + return { + error: { + code: "sign_in_not_enabled", + message: "App does not have Sign in with World ID enabled.", + statusCode: 400, + }, + }; + } + + const external_nullifier = app.actions[0].external_nullifier; + const action_id = app.actions[0].id; + const registered_redirect_uri = app.actions[0].redirects[0]?.redirect_uri; + + const sanitizedApp = { ...app }; + const { actions, ...rest } = sanitizedApp; + + return { + app: { + ...rest, + action_id, + external_nullifier, + registered_redirect_uri, + }, + }; +}; + +export const generateOIDCCode = async ( + app_id: string, + nullifier_hash: string, + verification_level: VerificationLevel, + scope: OIDCScopes[], + redirect_uri: string, + code_challenge?: string, + code_challenge_method?: string, + nonce?: string | null, +): Promise => { + // Generate a random code + const auth_code = crypto.randomBytes(12).toString("hex"); + const client = await getAPIServiceGraphqlClient(); + let data: InsertAuthCodeMutation | null = null; + + try { + data = await InsertAuthCodeSdk(client).InsertAuthCode({ + app_id, + auth_code, + code_challenge, + code_challenge_method, + expires_at: new Date(Date.now() + 1000 * 60 * 10).toISOString(), // 10 minutes + nullifier_hash, + verification_level, + scope, + nonce, + redirect_uri, + }); + } catch (error) { + logger.error("generateOIDCCode - Failed to generate auth code.", { error }); + throw error; + } + + if (data?.insert_auth_code_one?.auth_code !== auth_code) { + throw new Error("Failed to generate auth code."); + } + + return auth_code; +}; + +// TODO: Hash secrets as passwords (e.g. `PBKDF2`) instead of HMAC +export const authenticateOIDCEndpoint = async ( + auth_header: string, +): Promise => { + const authToken = auth_header.replace("Basic ", ""); + const [app_id, client_secret] = Buffer.from(authToken, "base64") + .toString() + .split(":"); + + // Fetch app + const client = await getAPIServiceGraphqlClient(); + + let data: FetchAppSecretQuery | null = null; + + try { + data = await FetchAppSecretQuerySdk(client).FetchAppSecret({ + app_id, + }); + } catch (error) { + logger.error("authenticateOIDCEndpoint - Failed to fetch app.", { error }); + return null; + } + + if (data.app.length === 0) { + logger.info("authenticateOIDCEndpoint - App not found or not active."); + return null; + } + + const hmac_secret = data.app[0]?.actions?.[0]?.client_secret; + + if (!hmac_secret) { + logger.info( + "authenticateOIDCEndpoint - App does not have Sign in with World ID enabled.", + ); + return null; + } + + // ANCHOR: Verify client secret + if (!verifyHashedSecret(app_id, client_secret, hmac_secret)) { + logger.warn("authenticateOIDCEndpoint - Invalid client secret."); + return null; + } + + return app_id; +}; + +export function checkFlowType(responseTypes: string[]) { + const includesAll = (requiredParams: string[]): boolean => { + return requiredParams.every((param) => responseTypes.includes(param)); + }; + + // NOTE: List of valid response types for the hybrid flow + // Source: https://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth:~:text=this%20value%20is%20code%C2%A0id_token%2C%20code%C2%A0token%2C%20or%20code%C2%A0id_token%C2%A0token. + if ( + includesAll([OIDCResponseType.Code, OIDCResponseType.IdToken]) || + includesAll([OIDCResponseType.Code, OIDCResponseType.Token]) || + includesAll([ + OIDCResponseType.Code, + OIDCResponseType.IdToken, + OIDCResponseType.Token, + ]) + ) { + return OIDCFlowType.Hybrid; + } + + // NOTE: List of valid response types for the code flow + // Source: https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth:~:text=Authorization%20Code%20Flow%2C-,this%20value%20is%20code.,-client_id + if (includesAll([OIDCResponseType.Code])) { + return OIDCFlowType.AuthorizationCode; + } + + // NOTE: List of valid response types for the implicit flow + // Source: https://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth:~:text=this%20value%20is%20id_token%C2%A0token%20or%20id_token + if ( + includesAll([OIDCResponseType.IdToken]) || + includesAll([OIDCResponseType.IdToken, OIDCResponseType.Token]) + ) { + return OIDCFlowType.Implicit; + } + + if (includesAll([OIDCResponseType.Token])) { + return OIDCFlowType.Token; + } + + return null; +} diff --git a/web/api/v1/jwks/graphql/get-jwks.generated.ts b/web/api/v1/jwks/graphql/get-jwks.generated.ts new file mode 100644 index 000000000..25301f89a --- /dev/null +++ b/web/api/v1/jwks/graphql/get-jwks.generated.ts @@ -0,0 +1,65 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type JwkQueryQueryVariables = Types.Exact<{ [key: string]: never }>; + +export type JwkQueryQuery = { + __typename?: "query_root"; + jwks: Array<{ + __typename?: "jwks"; + id: string; + expires_at: string; + key: any; + }>; +}; + +export const JwkQueryDocument = gql` + query JWKQuery { + jwks { + id + expires_at + key: public_jwk + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + JWKQuery( + variables?: JwkQueryQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(JwkQueryDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "JWKQuery", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/v1/jwks/graphql/get-jwks.graphql b/web/api/v1/jwks/graphql/get-jwks.graphql new file mode 100644 index 000000000..eb3d16db2 --- /dev/null +++ b/web/api/v1/jwks/graphql/get-jwks.graphql @@ -0,0 +1,7 @@ +query JWKQuery { + jwks { + id + expires_at + key: public_jwk + } +} diff --git a/web/api/v1/jwks/index.ts b/web/api/v1/jwks/index.ts new file mode 100644 index 000000000..96fc87413 --- /dev/null +++ b/web/api/v1/jwks/index.ts @@ -0,0 +1,28 @@ +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { corsHandler } from "@/api/helpers/utils"; +import { NextRequest, NextResponse } from "next/server"; +import { getSdk as getJWKsSdk } from "./graphql/get-jwks.generated"; + +const corsMethods = ["GET", "OPTIONS"]; + +/** + * Retrieves JWKs to verify proofs + * @param req + * @param res + */ +export async function GET(req: NextRequest) { + const client = await getAPIServiceGraphqlClient(); + const getJWKs = getJWKsSdk(client); + const response = await getJWKs.JWKQuery(); + + const keys = []; + for (const { id, key } of response.jwks) { + keys.push({ ...key, kid: id }); + } + + return corsHandler(NextResponse.json({ keys }), corsMethods); +} + +export async function OPTIONS(req: NextRequest) { + return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); +} diff --git a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts new file mode 100644 index 000000000..336bd1e05 --- /dev/null +++ b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.generated.ts @@ -0,0 +1,60 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type NullifierQueryVariables = Types.Exact<{ + nullifier_hash: Types.Scalars["String"]["input"]; +}>; + +export type NullifierQuery = { + __typename?: "query_root"; + nullifier: Array<{ __typename?: "nullifier"; id: string }>; +}; + +export const NullifierDocument = gql` + query Nullifier($nullifier_hash: String!) { + nullifier(where: { nullifier_hash: { _eq: $nullifier_hash } }) { + id + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + Nullifier( + variables: NullifierQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request(NullifierDocument, variables, { + ...requestHeaders, + ...wrappedRequestHeaders, + }), + "Nullifier", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql new file mode 100644 index 000000000..3ec6c2039 --- /dev/null +++ b/web/api/v1/oidc/authorize/graphql/fetch-nullifier.graphql @@ -0,0 +1,5 @@ +query Nullifier($nullifier_hash: String!) { + nullifier(where: { nullifier_hash: { _eq: $nullifier_hash } }) { + id + } +} diff --git a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts new file mode 100644 index 000000000..0becf53f3 --- /dev/null +++ b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.generated.ts @@ -0,0 +1,70 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type UpsertNullifierMutationVariables = Types.Exact<{ + object: Types.Nullifier_Insert_Input; + on_conflict: Types.Nullifier_On_Conflict; +}>; + +export type UpsertNullifierMutation = { + __typename?: "mutation_root"; + insert_nullifier_one?: { + __typename?: "nullifier"; + id: string; + nullifier_hash: string; + } | null; +}; + +export const UpsertNullifierDocument = gql` + mutation UpsertNullifier( + $object: nullifier_insert_input! + $on_conflict: nullifier_on_conflict! + ) { + insert_nullifier_one(object: $object, on_conflict: $on_conflict) { + id + nullifier_hash + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + UpsertNullifier( + variables: UpsertNullifierMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + UpsertNullifierDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "UpsertNullifier", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql new file mode 100644 index 000000000..92472526a --- /dev/null +++ b/web/api/v1/oidc/authorize/graphql/upsert-nullifier.graphql @@ -0,0 +1,9 @@ +mutation UpsertNullifier( + $object: nullifier_insert_input! + $on_conflict: nullifier_on_conflict! +) { + insert_nullifier_one(object: $object, on_conflict: $on_conflict) { + id + nullifier_hash + } +} diff --git a/web/api/v1/oidc/authorize/index.ts b/web/api/v1/oidc/authorize/index.ts new file mode 100644 index 000000000..934db0c32 --- /dev/null +++ b/web/api/v1/oidc/authorize/index.ts @@ -0,0 +1,439 @@ +import { errorResponse } from "@/api/helpers/errors"; +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { fetchActiveJWK } from "@/api/helpers/jwks"; +import { generateOIDCJWT } from "@/api/helpers/jwts"; +import { + OIDCErrorCodes, + OIDCResponseTypeMapping, + OIDCScopes, + checkFlowType, + fetchOIDCApp, + generateOIDCCode, +} from "@/api/helpers/oidc"; +import { corsHandler } from "@/api/helpers/utils"; +import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; +import { encodeNullifierForStorage, verifyProof } from "@/api/helpers/verify"; +import { Nullifier_Constraint } from "@/graphql/graphql"; +import { logger } from "@/lib/logger"; +import { OIDCFlowType, OIDCResponseType } from "@/lib/types"; +import { captureEvent } from "@/services/posthogClient"; +import { VerificationLevel } from "@worldcoin/idkit-core"; +import { hashToField } from "@worldcoin/idkit-core/hashing"; +import { createHash } from "crypto"; +import { toBeHex } from "ethers"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; +import { getSdk as getNullifierSdk } from "./graphql/fetch-nullifier.generated"; +import { getSdk as getUpsertNullifierSdk } from "./graphql/upsert-nullifier.generated"; + +// NOTE: This endpoint should only be called from Sign in with World, params follow World ID conventions. Sign in with World handles OIDC requests. +const schema = yup + .object({ + proof: yup.string().strict().required("This attribute is required."), + nullifier_hash: yup + .string() + .strict() + .required("This attribute is required."), + merkle_root: yup.string().strict().required("This attribute is required."), + verification_level: yup + .string() + .oneOf(Object.values(VerificationLevel)) + .required("This attribute is required."), + app_id: yup.string().strict().required("This attribute is required."), + signal: yup // `signal` in the context of World ID; `nonce` in the context of OIDC + .string() + .ensure() + .when("response_type", { + is: (response_type: string) => + !["code", "code token"].includes(response_type), + then: (nonce) => + nonce.required( + "`nonce` required for all response types except `code` and `code token`.", + ), + }), // NOTE: nonce is required for all response types except `code` and `code token` + code_challenge: yup.string(), + code_challenge_method: yup.string(), + scope: yup + .string() + .strict() + .required("The openid scope is always required."), + response_type: yup + .string() + .strict() + .required("This attribute is required."), + redirect_uri: yup.string().strict().required("This attribute is required."), + }) + .noUnknown(); + +const corsMethods = ["POST", "OPTIONS"]; +/** + * Authenticates a "Sign in with World ID" user with a ZKP and issues a JWT or a code (authorization code flow) + * This endpoint is called by the Sign in with World ID page (or the app's own page if using IDKit [advanced]) + */ +export async function POST(req: NextRequest) { + const redis = global.RedisClient; + + if (!redis) { + return corsHandler( + errorResponse({ + statusCode: 500, + code: "internal_server_error", + detail: "Redis client not found", + attribute: "server", + req, + }), + corsMethods, + ); + } + + let app_id: string | undefined; + + try { + const body = await req.json(); + const { isValid, parsedParams, handleError } = await validateRequestSchema({ + schema, + value: body, + }); + + if (!isValid) { + return handleError(req); + } + + app_id = parsedParams.app_id; + + const { + proof, + nullifier_hash, + merkle_root, + signal, + verification_level, + response_type, + scope, + redirect_uri, + code_challenge, + code_challenge_method, + } = parsedParams; + + const response_types = decodeURIComponent( + (response_type as string | string[]).toString(), + ).split(" "); + + for (const response_type of response_types) { + if (!Object.keys(OIDCResponseTypeMapping).includes(response_type)) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: OIDCErrorCodes.UnsupportedResponseType, + detail: `Invalid response type: ${response_type}.`, + attribute: "response_type", + req, + app_id, + }), + corsMethods, + ); + } + } + + if (code_challenge && code_challenge_method !== "S256") { + return corsHandler( + errorResponse({ + statusCode: 400, + code: OIDCErrorCodes.InvalidRequest, + detail: `Invalid code_challenge_method: ${code_challenge_method}.`, + attribute: "code_challenge_method", + req, + app_id, + }), + corsMethods, + ); + } + + const scopes = decodeURIComponent( + (scope as string | string[])?.toString(), + ).split(" ") as OIDCScopes[]; + const sanitizedScopes: OIDCScopes[] = scopes.length + ? [ + ...new Set( + // NOTE: Invalid scopes are ignored per spec (3.1.2.1) + scopes.filter((scope) => Object.values(OIDCScopes).includes(scope)), + ), + ] + : []; + + if ( + !sanitizedScopes.length || + !sanitizedScopes.includes(OIDCScopes.OpenID) + ) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: OIDCErrorCodes.InvalidScope, + detail: `The ${OIDCScopes.OpenID} scope is always required.`, + attribute: "scope", + req, + app_id, + }), + corsMethods, + ); + } + + // ANCHOR: Check the app is valid and fetch information + const { app, error: fetchAppError } = await fetchOIDCApp( + app_id, + redirect_uri, + ); + if (!app || fetchAppError) { + return corsHandler( + errorResponse({ + statusCode: fetchAppError?.statusCode ?? 400, + code: fetchAppError?.code ?? "error", + detail: fetchAppError?.message ?? "Error fetching app.", + attribute: fetchAppError?.attribute ?? "app_id", + req, + app_id, + }), + corsMethods, + ); + } + + // ANCHOR: Verify redirect URI is valid + if (app.registered_redirect_uri !== redirect_uri) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: OIDCErrorCodes.InvalidRedirectURI, + detail: "Invalid redirect URI.", + attribute: "redirect_uri", + req, + app_id, + }), + corsMethods, + ); + } + + // Anchor: Check the proof hasn't been replayed + let hashedProof: string; + try { + hashedProof = createHash("sha256").update(proof).digest("hex"); + } catch (error) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: "invalid_proof", + detail: "Provided proof is invalid.", + attribute: "proof", + req, + app_id, + }), + corsMethods, + ); + } + const proofKey = `oidc:proof:${hashedProof}`; + const isProofReplayed = await redis.get(proofKey); + + if (isProofReplayed) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: "invalid_proof", + detail: "This proof has already been used. Please try again", + attribute: "proof", + req, + app_id, + }), + corsMethods, + ); + } + + // Set the proof before continuing with other operations + await redis.set(proofKey, "1", "EX", 5400); + + // For OIDC we should always hash the signal now. + let signalHash: string; + try { + signalHash = toBeHex(hashToField(signal).hash as bigint); + } catch (error) { + return corsHandler( + errorResponse({ + statusCode: 400, + code: "invalid_signal", + detail: "Provided signal is invalid.", + attribute: "signal", + req, + app_id, + }), + corsMethods, + ); + } + + // ANCHOR: Verify the zero-knowledge proof + const { error: verifyError } = await verifyProof( + { + proof, + nullifier_hash, + merkle_root, + signal_hash: signalHash, + external_nullifier: app.external_nullifier, + }, + { + is_staging: app.is_staging, + verification_level, + max_age: 3600, // require that root be less than 1 hour old + }, + ); + + if (verifyError) { + return corsHandler( + errorResponse({ + statusCode: verifyError.statusCode ?? 400, + code: verifyError.code ?? "invalid_proof", + detail: + verifyError.message ?? + "Verification request error. Please try again.", + attribute: verifyError.attribute, + req, + app_id, + }), + corsMethods, + ); + } + + // ANCHOR: Proof is valid, issue relevant codes + const response = {} as { code?: string; id_token?: string; token?: string }; + + if (response_types.includes(OIDCResponseType.Code)) { + const shouldStoreSignal = + checkFlowType(response_types) === OIDCFlowType.AuthorizationCode && + signal; + + response.code = await generateOIDCCode( + app.id, + nullifier_hash, + verification_level, + sanitizedScopes, + redirect_uri, + code_challenge, + code_challenge_method, + shouldStoreSignal ? signal : null, + ); + } + + let jwt: string | undefined; + for (const response_type of response_types) { + if ( + OIDCResponseTypeMapping[ + response_type as keyof typeof OIDCResponseTypeMapping + ] === OIDCResponseType.JWT + ) { + if (!jwt) { + const jwk = await fetchActiveJWK(); + + jwt = await generateOIDCJWT({ + app_id: app.id, + nullifier_hash, + verification_level, + nonce: signal, + scope: sanitizedScopes, + kid: jwk.kid, + kms_id: jwk.kms_id ?? "", + }); + } + + response[response_type as keyof typeof OIDCResponseTypeMapping] = jwt; + } + } + + const client = await getAPIServiceGraphqlClient(); + const nullifierSdk = getNullifierSdk(client); + const upsertNullifierSdk = getUpsertNullifierSdk(client); + + let hasNullifier: boolean = false; + + try { + const fetchNullifierResult = await nullifierSdk.Nullifier({ + nullifier_hash, + }); + + if (!fetchNullifierResult?.nullifier) { + logger.warn("Error fetching nullifier.", { + fetchNullifierResult: fetchNullifierResult ?? {}, + app_id, + }); + hasNullifier = false; + } + hasNullifier = Boolean(fetchNullifierResult.nullifier?.[0]?.id); + } catch (error) { + // Temp Fix to reduce on call alerts + logger.warn("Query error nullifier.", { + nullifier_hash, + errorMessage: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + app_id, + }); + } + + if (!hasNullifier) { + try { + const { insert_nullifier_one } = + await upsertNullifierSdk.UpsertNullifier({ + object: { + nullifier_hash, + action_id: app.action_id, + nullifier_hash_int: encodeNullifierForStorage(nullifier_hash), + }, + on_conflict: { + constraint: Nullifier_Constraint.NullifierPkey, + }, + }); + + if (!insert_nullifier_one) { + logger.error("Error inserting nullifier.", { + insert_nullifier_one: insert_nullifier_one ?? {}, + app_id, + }); + } + } catch (error) { + logger.error("Generic Error inserting nullifier", { + req, + error, + app_id, + }); + } + } + + await captureEvent({ + event: "world_id_sign_in_success", + distinctId: app.id, + properties: { + verification_level: verification_level, + }, + }); + + return corsHandler(NextResponse.json(response, { status: 200 }), [ + "POST", + "OPTIONS", + ]); + } catch (error) { + // Handle any unexpected errors + logger.error("Unexpected error in OIDC authorize", { + error, + app_id, + }); + + return corsHandler( + errorResponse({ + statusCode: 500, + code: "internal_server_error", + detail: "An unexpected error occurred", + attribute: "server", + req, + app_id, + }), + corsMethods, + ); + } +} + +export async function OPTIONS(req: NextRequest) { + return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); +} diff --git a/web/api/v1/oidc/introspect/index.ts b/web/api/v1/oidc/introspect/index.ts new file mode 100644 index 000000000..2f9393ab2 --- /dev/null +++ b/web/api/v1/oidc/introspect/index.ts @@ -0,0 +1,75 @@ +import { + errorResponse, + errorUnauthenticated, + errorValidation, +} from "@/api/helpers/errors"; +import { verifyOIDCJWT } from "@/api/helpers/jwts"; +import { authenticateOIDCEndpoint } from "@/api/helpers/oidc"; +import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; + +const schema = yup + .object({ + token: yup.string().strict().required("This attribute is required."), + }) + .noUnknown(); + +export async function POST(req: NextRequest) { + if (req.headers.get("content-type") !== "application/x-www-form-urlencoded") { + return errorValidation( + "invalid_content_type", + "Invalid content type. Only application/x-www-form-urlencoded is supported.", + null, + req, + ); + } + + const { isValid, parsedParams, handleError } = await validateRequestSchema({ + schema, + value: req.body, + }); + + if (!isValid) { + return handleError(req); + } + + const userToken = parsedParams.token; + + // ANCHOR: Authenticate the request comes from the app + const authHeader = req.headers.get("authorization"); + + if (!authHeader) { + return errorUnauthenticated( + "Please provide your app authentication credentials.", + req, + ); + } + + let app_id: string | null; + app_id = await authenticateOIDCEndpoint(authHeader); + + if (!app_id) { + return errorUnauthenticated("Invalid authentication credentials.", req); + } + + try { + const payload = await verifyOIDCJWT(userToken); + + return NextResponse.json({ + active: true, + client_id: app_id, + exp: payload.exp, + sub: payload.sub, + }); + } catch { + return errorResponse({ + statusCode: 401, + code: "invalid_token", + detail: "Token is invalid or expired.", + attribute: "token", + req, + app_id, + }); + } +} diff --git a/web/api/v1/oidc/openid-configuration/index.ts b/web/api/v1/oidc/openid-configuration/index.ts new file mode 100644 index 000000000..a0c74103d --- /dev/null +++ b/web/api/v1/oidc/openid-configuration/index.ts @@ -0,0 +1,38 @@ +import { JWT_ISSUER } from "@/api/helpers/jwts"; +import { OIDCScopes } from "@/api/helpers/oidc"; +import { OIDC_BASE_URL } from "@/lib/constants"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Returns an OpenID Connect discovery document, according to spec + * https://openid.net/specs/openid-connect-discovery-1_0.html + * @param req + */ +export async function GET(req: NextRequest) { + return NextResponse.json({ + issuer: JWT_ISSUER, + jwks_uri: `${OIDC_BASE_URL}/jwks.json`, + token_endpoint: `${OIDC_BASE_URL}/token`, + code_challenge_methods_supported: ["S256"], + scopes_supported: Object.values(OIDCScopes), + id_token_signing_alg_values_supported: ["RSA"], + userinfo_endpoint: `${OIDC_BASE_URL}/userinfo`, + authorization_endpoint: `${OIDC_BASE_URL}/authorize`, + grant_types_supported: ["authorization_code", "implicit"], + service_documentation: "https://docs.world.org/world-id", + op_policy_uri: "https://developer.world.org/privacy-statement", + op_tos_uri: "https://developer.world.org/tos", + subject_types_supported: ["pairwise"], // subject is unique to each application, cannot be used across + response_modes_supported: ["query", "fragment", "form_post"], + response_types_supported: [ + "code", // Authorization code flow + "id_token", // Implicit flow + "id_token token", // Implicit flow + "code id_token", // Hybrid flow + ], + }); +} + +export async function OPTIONS(req: NextRequest) { + return NextResponse.json(null, { status: 204 }); +} diff --git a/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts b/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts new file mode 100644 index 000000000..db825c384 --- /dev/null +++ b/web/api/v1/oidc/token/graphql/delete-auth-code.generated.ts @@ -0,0 +1,95 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type DeleteAuthCodeMutationVariables = Types.Exact<{ + auth_code: Types.Scalars["String"]["input"]; + app_id: Types.Scalars["String"]["input"]; + now: Types.Scalars["timestamptz"]["input"]; +}>; + +export type DeleteAuthCodeMutation = { + __typename?: "mutation_root"; + delete_auth_code?: { + __typename?: "auth_code_mutation_response"; + affected_rows: number; + returning: Array<{ + __typename?: "auth_code"; + nullifier_hash: string; + verification_level: string; + scope?: any | null; + code_challenge?: string | null; + code_challenge_method?: string | null; + redirect_uri: string; + nonce?: string | null; + }>; + } | null; +}; + +export const DeleteAuthCodeDocument = gql` + mutation DeleteAuthCode( + $auth_code: String! + $app_id: String! + $now: timestamptz! + ) { + delete_auth_code( + where: { + app_id: { _eq: $app_id } + expires_at: { _gt: $now } + auth_code: { _eq: $auth_code } + } + ) { + returning { + nullifier_hash + verification_level + scope + code_challenge + code_challenge_method + redirect_uri + nonce + } + affected_rows + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + DeleteAuthCode( + variables: DeleteAuthCodeMutationVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + DeleteAuthCodeDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "DeleteAuthCode", + "mutation", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/token/graphql/delete-auth-code.graphql b/web/api/v1/oidc/token/graphql/delete-auth-code.graphql new file mode 100644 index 000000000..167833fb7 --- /dev/null +++ b/web/api/v1/oidc/token/graphql/delete-auth-code.graphql @@ -0,0 +1,24 @@ +mutation DeleteAuthCode( + $auth_code: String! + $app_id: String! + $now: timestamptz! +) { + delete_auth_code( + where: { + app_id: { _eq: $app_id } + expires_at: { _gt: $now } + auth_code: { _eq: $auth_code } + } + ) { + returning { + nullifier_hash + verification_level + scope + code_challenge + code_challenge_method + redirect_uri + nonce + } + affected_rows + } +} diff --git a/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts b/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts new file mode 100644 index 000000000..58394fb0b --- /dev/null +++ b/web/api/v1/oidc/token/graphql/fetch-redirect-count.generated.ts @@ -0,0 +1,61 @@ +/* eslint-disable import/no-relative-parent-imports -- auto generated file */ +import * as Types from "@/graphql/graphql"; + +import { GraphQLClient, RequestOptions } from "graphql-request"; +import gql from "graphql-tag"; +type GraphQLClientRequestHeaders = RequestOptions["requestHeaders"]; +export type FetchRedirectCountQueryQueryVariables = Types.Exact<{ + app_id?: Types.InputMaybe; +}>; + +export type FetchRedirectCountQueryQuery = { + __typename?: "query_root"; + action: Array<{ __typename?: "action"; redirect_count?: number | null }>; +}; + +export const FetchRedirectCountQueryDocument = gql` + query FetchRedirectCountQuery($app_id: String) { + action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { + redirect_count + } + } +`; + +export type SdkFunctionWrapper = ( + action: (requestHeaders?: Record) => Promise, + operationName: string, + operationType?: string, + variables?: any, +) => Promise; + +const defaultWrapper: SdkFunctionWrapper = ( + action, + _operationName, + _operationType, + _variables, +) => action(); + +export function getSdk( + client: GraphQLClient, + withWrapper: SdkFunctionWrapper = defaultWrapper, +) { + return { + FetchRedirectCountQuery( + variables?: FetchRedirectCountQueryQueryVariables, + requestHeaders?: GraphQLClientRequestHeaders, + ): Promise { + return withWrapper( + (wrappedRequestHeaders) => + client.request( + FetchRedirectCountQueryDocument, + variables, + { ...requestHeaders, ...wrappedRequestHeaders }, + ), + "FetchRedirectCountQuery", + "query", + variables, + ); + }, + }; +} +export type Sdk = ReturnType; diff --git a/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql b/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql new file mode 100644 index 000000000..ff511db8e --- /dev/null +++ b/web/api/v1/oidc/token/graphql/fetch-redirect-count.graphql @@ -0,0 +1,5 @@ +query FetchRedirectCountQuery($app_id: String) { + action(where: { app_id: { _eq: $app_id }, action: { _eq: "" } }) { + redirect_count + } +} diff --git a/web/api/v1/oidc/token/index.ts b/web/api/v1/oidc/token/index.ts new file mode 100644 index 000000000..d299b991f --- /dev/null +++ b/web/api/v1/oidc/token/index.ts @@ -0,0 +1,244 @@ +import { errorOIDCResponse } from "@/api/helpers/errors"; +import { getAPIServiceGraphqlClient } from "@/api/helpers/graphql"; +import { fetchActiveJWK } from "@/api/helpers/jwks"; +import { generateOIDCJWT } from "@/api/helpers/jwts"; +import { authenticateOIDCEndpoint } from "@/api/helpers/oidc"; +import { corsHandler } from "@/api/helpers/utils"; +import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; +import { VerificationLevel } from "@worldcoin/idkit-core"; +import { createHash, timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; +import { getSdk as getDeleteAuthCodeSdk } from "./graphql/delete-auth-code.generated"; +import { getSdk as getFetchRedirectCountSdk } from "./graphql/fetch-redirect-count.generated"; + +const corsMethods = ["POST", "OPTIONS"]; +const schema = yup + .object({ + grant_type: yup.string().default("authorization_code"), + code: yup.string().strict().required("This attribute is required."), + redirect_uri: yup.string().notRequired(), + client_id: yup.string().notRequired(), + client_secret: yup.string().notRequired(), + code_verifier: yup.string().notRequired(), + }) + .noUnknown(); + +export async function POST(req: NextRequest) { + if ( + !req.headers + .get("content-type") + ?.toLowerCase() + .startsWith("application/x-www-form-urlencoded") + ) { + const sanitizedContentType = req.headers + .get("content-type") + ?.replace(/\n|\r/g, ""); + console.warn("Invalid content type", sanitizedContentType); + return errorOIDCResponse( + 400, + "invalid_request", + "Invalid content type. Only application/x-www-form-urlencoded is supported.", + null, + req, + ); + } + + // ANCHOR: Authenticate the request + let authToken = req.headers.get("authorization"); + + const rawBody = await req.text(); + const params = new URLSearchParams(rawBody); + const body = Object.fromEntries(params.entries()); + const { isValid, parsedParams, handleError } = await validateRequestSchema({ + schema, + value: body, + }); + + if (!isValid) { + console.log("Invalid request", parsedParams); + return handleError(req); + } + + const { + grant_type, + code, + redirect_uri, + client_id, + client_secret, + code_verifier, + } = parsedParams; + + if (!authToken) { + // Attempt to get the credentials in the request body + if (client_id && client_secret) { + authToken = `Basic ${Buffer.from( + `${client_id}:${client_secret}`, + ).toString("base64")}`; + } + } + + if (!authToken) { + const response = errorOIDCResponse( + 401, + "unauthorized_client", + "Please provide your app authentication credentials.", + null, + req, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + + let app_id: string | null; + app_id = await authenticateOIDCEndpoint(authToken); + + if (!app_id) { + const response = errorOIDCResponse( + 401, + "unauthorized_client", + "Invalid authentication credentials", + null, + req, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + + const client = await getAPIServiceGraphqlClient(); + const deleteAuthCodeSdk = getDeleteAuthCodeSdk(client); + const fetchRedirectCountSdk = getFetchRedirectCountSdk(client); + const now = new Date().toISOString(); + const deleteAuthCodeResult = await deleteAuthCodeSdk.DeleteAuthCode({ + auth_code: code, + app_id, + now, + }); + + if ( + !deleteAuthCodeResult?.delete_auth_code || + !deleteAuthCodeResult.delete_auth_code.returning.length + ) { + const response = errorOIDCResponse( + 400, + "invalid_grant", + "Invalid authorization code.", + null, + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + + const authCode = deleteAuthCodeResult.delete_auth_code.returning[0]; + + if (!redirect_uri) { + const redirectCountResult = + await fetchRedirectCountSdk.FetchRedirectCountQuery({ + app_id, + }); + if ( + redirectCountResult?.action?.[0]?.redirect_count && + redirectCountResult?.action[0].redirect_count > 1 + ) { + const response = errorOIDCResponse( + 400, + "invalid_request", + "Missing redirect URI.", + "redirect_uri", + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + } else if (authCode.redirect_uri !== redirect_uri) { + const response = errorOIDCResponse( + 400, + "invalid_request", + "Invalid redirect URI.", + "redirect_uri", + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + + if (authCode.code_challenge) { + if (!code_verifier) { + const response = errorOIDCResponse( + 400, + "invalid_request", + "Missing code verifier.", + "code_verifier", + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + + // We only support S256 method + if (!verifyChallenge(authCode.code_challenge, code_verifier)) { + await deleteAuthCodeSdk.DeleteAuthCode({ + auth_code: code, + app_id, + now, + }); + + const response = errorOIDCResponse( + 400, + "invalid_request", + "Invalid code verifier.", + "code_verifier", + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + } else { + if (code_verifier) { + const response = errorOIDCResponse( + 400, + "invalid_request", + "Code verifier was not expected.", + "code_verifier", + req, + app_id, + ); + return code_verifier ? corsHandler(response, corsMethods) : response; + } + } + + const jwk = await fetchActiveJWK(); + const token = await generateOIDCJWT({ + app_id, + nullifier_hash: authCode.nullifier_hash, + verification_level: authCode.verification_level as VerificationLevel, + kid: jwk.kid, + kms_id: jwk.kms_id ?? "", + scope: authCode.scope, + nonce: authCode.nonce || undefined, + }); + + const response = NextResponse.json({ + access_token: token, + token_type: "Bearer", + expires_in: 3600, + scope: authCode.scope?.join(" ") || "", + id_token: token, + }); + + return code_verifier ? corsHandler(response, corsMethods) : response; +} + +const verifyChallenge = (challenge: string, verifier: string) => { + const hashedVerifier = createHash("sha256") + .update(verifier) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + return timingSafeEqual(Buffer.from(challenge), Buffer.from(hashedVerifier)); +}; + +export async function OPTIONS(req: NextRequest) { + return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); +} diff --git a/web/api/v1/oidc/userinfo/index.ts b/web/api/v1/oidc/userinfo/index.ts new file mode 100644 index 000000000..b619d3529 --- /dev/null +++ b/web/api/v1/oidc/userinfo/index.ts @@ -0,0 +1,109 @@ +import { errorResponse, errorUnauthenticated } from "@/api/helpers/errors"; +import { verifyOIDCJWT } from "@/api/helpers/jwts"; +import { corsHandler } from "@/api/helpers/utils"; +import { NextRequest, NextResponse } from "next/server"; + +const corsMethods = ["GET", "POST", "OPTIONS"]; + +/** + * Handles GET requests for the userinfo endpoint + */ +export async function GET(req: NextRequest) { + const authorization = req.headers.get("authorization"); + if (!authorization) { + return corsHandler( + errorUnauthenticated("Missing credentials.", req), + corsMethods, + ); + } + + const token = authorization.replace("Bearer ", ""); + + try { + const payload = await verifyOIDCJWT(token); + const response: Record = { + sub: payload.sub, + "https://id.worldcoin.org/beta": payload["https://id.worldcoin.org/beta"], + "https://id.worldcoin.org/v1": payload["https://id.worldcoin.org/v1"], + }; + const scopes = (payload.scope as string)?.toString().split(" "); + + if (scopes?.includes("email")) { + response.email = `${payload.sub}@id.worldcoin.org`; + } + + if (scopes?.includes("profile")) { + response.name = "World ID User"; + response.given_name = "World ID"; + response.family_name = "User"; + } + + return corsHandler(NextResponse.json(response), corsMethods); + } catch { + return corsHandler( + errorResponse({ + statusCode: 401, + code: "invalid_token", + detail: "Token is invalid or expired.", + attribute: "token", + req, + }), + corsMethods, + ); + } +} + +/** + * Handles POST requests for the userinfo endpoint + */ +export async function POST(req: NextRequest) { + const authorization = req.headers.get("authorization"); + if (!authorization) { + return corsHandler( + errorUnauthenticated("Missing credentials.", req), + corsMethods, + ); + } + + const token = authorization.replace("Bearer ", ""); + + try { + const payload = await verifyOIDCJWT(token); + const response: Record = { + sub: payload.sub, + "https://id.worldcoin.org/beta": payload["https://id.worldcoin.org/beta"], + "https://id.worldcoin.org/v1": payload["https://id.worldcoin.org/v1"], + }; + const scopes = (payload.scope as string)?.toString().split(" "); + + if (scopes?.includes("email")) { + response.email = `${payload.sub}@id.worldcoin.org`; + } + + if (scopes?.includes("profile")) { + response.name = "World ID User"; + response.given_name = "World ID"; + response.family_name = "User"; + } + + return corsHandler(NextResponse.json(response), corsMethods); + } catch { + return corsHandler( + errorResponse({ + statusCode: 401, + code: "invalid_token", + detail: "Token is invalid or expired.", + attribute: "token", + req, + }), + corsMethods, + ); + } +} + +/** + * Handles OPTIONS requests + */ +export async function OPTIONS(req: NextRequest) { + return corsHandler(new NextResponse(null, { status: 204 }), corsMethods); +} diff --git a/web/api/v1/oidc/validate/index.ts b/web/api/v1/oidc/validate/index.ts new file mode 100644 index 000000000..033caeb23 --- /dev/null +++ b/web/api/v1/oidc/validate/index.ts @@ -0,0 +1,69 @@ +import { errorResponse } from "@/api/helpers/errors"; +import { fetchOIDCApp } from "@/api/helpers/oidc"; +import { validateRequestSchema } from "@/api/helpers/validate-request-schema"; +import { validateUrl } from "@/lib/utils"; +import { NextRequest, NextResponse } from "next/server"; +import * as yup from "yup"; + +const schema = yup + .object({ + app_id: yup.string().strict().required("This attribute is required."), + redirect_uri: yup.string().strict().required("This attribute is required."), + }) + .noUnknown(); + +/** + * Prevalidates app_id & redirect_uri is valid for Sign in with World ID for early user feedback + */ +export async function POST(req: NextRequest) { + const body = await req.json(); + const { isValid, parsedParams, handleError } = await validateRequestSchema({ + schema, + value: body, + }); + + if (!isValid) { + return handleError(req); + } + + const { app_id, redirect_uri } = parsedParams; + + const { app, error: fetchAppError } = await fetchOIDCApp( + app_id, + redirect_uri, + ); + if (!app || fetchAppError) { + return errorResponse({ + statusCode: fetchAppError?.statusCode ?? 400, + code: fetchAppError?.code ?? "error", + detail: fetchAppError?.message ?? "Error fetching app.", + attribute: fetchAppError?.attribute ?? "app_id", + req, + app_id, + }); + } + + if (!validateUrl(redirect_uri, app.is_staging)) { + return errorResponse({ + statusCode: 400, + code: "invalid_redirect_uri", + detail: "Invalid redirect_uri provided.", + attribute: "redirect_uri", + req, + app_id, + }); + } + + if (app.registered_redirect_uri !== redirect_uri) { + return errorResponse({ + statusCode: 400, + code: "invalid_redirect_uri", + detail: "Invalid redirect_uri provided.", + attribute: "redirect_uri", + req, + app_id, + }); + } + + return NextResponse.json({ app_id, redirect_uri }); +} diff --git a/web/app/api/hasura/reset-client-secret/route.ts b/web/app/api/hasura/reset-client-secret/route.ts new file mode 100644 index 000000000..d2cf1a07f --- /dev/null +++ b/web/app/api/hasura/reset-client-secret/route.ts @@ -0,0 +1 @@ +export { POST } from "@/api/hasura/reset-client-secret"; diff --git a/web/app/api/v1/jwks/route.ts b/web/app/api/v1/jwks/route.ts new file mode 100644 index 000000000..243e17c1b --- /dev/null +++ b/web/app/api/v1/jwks/route.ts @@ -0,0 +1 @@ +export { GET, OPTIONS } from "@/api/v1/jwks"; diff --git a/web/app/api/v1/oidc/authorize/route.ts b/web/app/api/v1/oidc/authorize/route.ts new file mode 100644 index 000000000..81d47643c --- /dev/null +++ b/web/app/api/v1/oidc/authorize/route.ts @@ -0,0 +1 @@ +export { OPTIONS, POST } from "@/api/v1/oidc/authorize"; diff --git a/web/app/api/v1/oidc/introspect/route.ts b/web/app/api/v1/oidc/introspect/route.ts new file mode 100644 index 000000000..d53cf057c --- /dev/null +++ b/web/app/api/v1/oidc/introspect/route.ts @@ -0,0 +1 @@ +export { POST } from "@/api/v1/oidc/introspect"; diff --git a/web/app/api/v1/oidc/openid-configuration/route.ts b/web/app/api/v1/oidc/openid-configuration/route.ts new file mode 100644 index 000000000..3f619371b --- /dev/null +++ b/web/app/api/v1/oidc/openid-configuration/route.ts @@ -0,0 +1 @@ +export { GET, OPTIONS } from "@/api/v1/oidc/openid-configuration"; diff --git a/web/app/api/v1/oidc/token/route.ts b/web/app/api/v1/oidc/token/route.ts new file mode 100644 index 000000000..5dfb64417 --- /dev/null +++ b/web/app/api/v1/oidc/token/route.ts @@ -0,0 +1 @@ +export { OPTIONS, POST } from "@/api/v1/oidc/token"; diff --git a/web/app/api/v1/oidc/userinfo/route.ts b/web/app/api/v1/oidc/userinfo/route.ts new file mode 100644 index 000000000..af5c26546 --- /dev/null +++ b/web/app/api/v1/oidc/userinfo/route.ts @@ -0,0 +1 @@ +export { GET, OPTIONS, POST } from "@/api/v1/oidc/userinfo"; diff --git a/web/app/api/v1/oidc/validate/route.ts b/web/app/api/v1/oidc/validate/route.ts new file mode 100644 index 000000000..eae4d7f0c --- /dev/null +++ b/web/app/api/v1/oidc/validate/route.ts @@ -0,0 +1 @@ +export { POST } from "@/api/v1/oidc/validate"; diff --git a/web/lib/constants.ts b/web/lib/constants.ts index d048d3f3b..1bbba29e0 100644 --- a/web/lib/constants.ts +++ b/web/lib/constants.ts @@ -34,9 +34,15 @@ export const SECURE_DOCUMENT_SEQUENCER_STAGING = export const FACE_SEQUENCER_STAGING = "https://signup-face.stage-crypto.worldcoin.dev"; +// ANCHOR: OIDC Base URL +export const OIDC_BASE_URL = "https://id.worldcoin.org"; export const DOCS_URL = "https://docs.world.org"; export const DOCS_CLOUD_URL = "https://docs.world.org/id/cloud"; +// ANCHOR: JWKs +export const JWK_TIME_TO_LIVE = 30; // days; duration before a JWK is rotated +export const JWK_TTL_USABLE = 7; // days; duration before a JWK is rotated + export const SIMULATOR_URL = "https://simulator.worldcoin.org"; export const TELEGRAM_DEVELOPERS_GROUP_URL = "https://t.me/worldcoindevelopers"; export const TELEGRAM_MATEO_URL = "https://t.me/MateoSauton"; diff --git a/web/lib/types.ts b/web/lib/types.ts index 3a4effcb0..df818d000 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -1,3 +1,10 @@ +export enum OIDCFlowType { + AuthorizationCode = "authorization_code", + Implicit = "implicit", + Hybrid = "hybrid", + Token = "token", +} + import { InsertMembershipMutation } from "@/api/create-team/graphql/insert-membership.generated"; /** * This file contains the main types for both the frontend and backend. @@ -56,6 +63,13 @@ export type ActionStatsModel = Array<{ total_cumulative: number; }>; +export enum OIDCResponseType { + Code = "code", // authorization code + JWT = "jwt", // implicit flow + IdToken = "id_token", + Token = "token", +} + export interface IInternalError { message: string; code: string; diff --git a/web/next.config.mjs b/web/next.config.mjs index 72fad492a..0e9cd9ff1 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -86,6 +86,10 @@ const nextConfig = { async rewrites() { return [ + { + source: "/.well-known/openid-configuration", + destination: "/api/v1/oidc/openid-configuration", + }, { source: "/ingest/:path*", destination: "https://app.posthog.com/:path*", diff --git a/web/tests/api/__mocks__/jwks.mock.ts b/web/tests/api/__mocks__/jwks.mock.ts new file mode 100644 index 000000000..53ac3439a --- /dev/null +++ b/web/tests/api/__mocks__/jwks.mock.ts @@ -0,0 +1,10 @@ +module.exports = { + retrieveJWK: jest.fn().mockImplementation(() => ({ + kid: "kid_my_test_key", + kms_id: "kms_my_test_id", + })), + fetchActiveJWK: jest.fn().mockImplementation(() => ({ + kid: "kid_my_test_key", + kms_id: "kms_my_test_id", + })), +}; diff --git a/web/tests/api/__mocks__/kms.mock.ts b/web/tests/api/__mocks__/kms.mock.ts index 072f83c3d..11dd702de 100644 --- a/web/tests/api/__mocks__/kms.mock.ts +++ b/web/tests/api/__mocks__/kms.mock.ts @@ -1,5 +1,5 @@ -import { createPrivateKey, createSign } from "crypto"; -import { privateJwk } from "./jwk"; +import { createPrivateKey, createPublicKey, createSign } from "crypto"; +import { privateJwk, publicJwk } from "./jwk"; module.exports = { getKMSClient: jest.fn().mockImplementation(() => ({ @@ -16,5 +16,14 @@ module.exports = { }; }), })), + signJWTWithKMSKey: jest.requireActual("@/api/helpers/kms").signJWTWithKMSKey, + createKMSKey: jest.fn().mockImplementation(async () => { + const key = createPublicKey({ format: "jwk", key: publicJwk }); + const pemKey = key.export({ type: "pkcs1", format: "pem" }); + return { + keyId: "test-kms-key-id", + publicKey: pemKey, + }; + }), scheduleKeyDeletion: jest.fn(), }; diff --git a/web/tests/api/delete-jwks.test.ts b/web/tests/api/delete-jwks.test.ts new file mode 100644 index 000000000..8145400d6 --- /dev/null +++ b/web/tests/api/delete-jwks.test.ts @@ -0,0 +1,112 @@ +import { POST } from "@/api/_delete-jwks"; +import { logger } from "@/lib/logger"; +import { NextRequest } from "next/server"; + +let consoleInfoSpy: jest.SpyInstance; + +jest.mock("@/api/helpers/graphql", () => ({ + getAPIServiceGraphqlClient: jest.fn(), +})); +const DeleteExpiredJWKs = jest.fn(); +jest.mock("@/api/helpers/jwks/graphql/delete-expired-jwks.generated", () => ({ + getSdk: () => ({ + DeleteExpiredJWKs, + }), +})); + +beforeEach(() => { + consoleInfoSpy = jest + .spyOn(logger, "info") + .mockImplementation(async () => {}); +}); + +afterEach(() => { + consoleInfoSpy.mockRestore(); +}); + +describe("/api/v1/_delete-jwks", () => { + test("endpoint is only accessible with specific token (Hasura)", async () => { + const request = new NextRequest( + "http://localhost:3000/api/v1/_delete-jwks", + { + method: "POST", + }, + ); + + const response = await POST(request); + + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ + code: "permission_denied", + detail: "You do not have permission to perform this action.", + attribute: null, + }); + }); + + test("will not delete jwks if none are expired", async () => { + const request = new NextRequest( + "http://localhost:3000/api/v1/_delete-jwks", + { + method: "POST", + headers: { + authorization: process.env.INTERNAL_ENDPOINTS_SECRET || "", + }, + }, + ); + + DeleteExpiredJWKs.mockResolvedValue({ + delete_jwks: { returning: [], __typename: "jwks_mutation_response" }, + }); + + const response = await POST(request); + + expect(response?.status).toBe(204); + expect(consoleInfoSpy).toHaveBeenCalledTimes(2); + expect(consoleInfoSpy).toHaveBeenNthCalledWith( + 1, + "Starting deletion of expired jwks.", + ); + expect(consoleInfoSpy).toHaveBeenNthCalledWith( + 2, + "Deleted 0 expired jwks.", + ); + }); + + test("will delete all jwks if past expiration date", async () => { + const request = new NextRequest( + "http://localhost:3000/api/v1/_delete-jwks", + { + method: "POST", + headers: { + authorization: process.env.INTERNAL_ENDPOINTS_SECRET || "", + }, + }, + ); + + DeleteExpiredJWKs.mockResolvedValue({ + delete_jwks: { + returning: [ + { + id: "jwk_4b4e07011e4766c69062b90ff384afc4", + kms_id: "f9f0ba3b-054b-4c27-bcc4-3c22c328117e", + __typename: "jwks", + }, + ], + __typename: "jwks_mutation_response", + }, + }); + + const response = await POST(request); + + expect(response?.status).toBe(204); + expect(consoleInfoSpy).toHaveBeenCalledTimes(2); + expect(consoleInfoSpy).toHaveBeenNthCalledWith( + 1, + "Starting deletion of expired jwks.", + ); + expect(consoleInfoSpy).toHaveBeenNthCalledWith( + 2, + "Deleted 1 expired jwks.", + ); + }); +}); diff --git a/web/tests/api/v1/oidc/authorize.test.ts b/web/tests/api/v1/oidc/authorize.test.ts new file mode 100644 index 000000000..a78ea0b34 --- /dev/null +++ b/web/tests/api/v1/oidc/authorize.test.ts @@ -0,0 +1,375 @@ +import { OIDCErrorCodes, OIDCScopes } from "@/api/helpers/oidc"; +import { POST } from "@/api/v1/oidc/authorize"; +import { OIDCResponseType } from "@/lib/types"; +import { createPublicKey } from "crypto"; +import dayjs from "dayjs"; +import { jwtVerify } from "jose"; +import { NextRequest } from "next/server"; +import { publicJwk } from "../../__mocks__/jwk"; +import { semaphoreProofParamsMock } from "../../__mocks__/proof.mock"; + +// Mock the external dependencies +jest.mock("@/api/helpers/graphql", () => ({ + getAPIServiceGraphqlClient: jest.fn(), +})); + +jest.mock("@/api/helpers/kms", () => + require("tests/api/__mocks__/kms.mock.ts"), +); + +jest.mock("@/api/helpers/jwks", () => + require("tests/api/__mocks__/jwks.mock.ts"), +); + +// Mock the GraphQL SDKs +const FetchOIDCApp = jest.fn(); +const Nullifier = jest.fn(); +const UpsertNullifier = jest.fn(); +const InsertAuthCode = jest.fn(); + +jest.mock("@/api/helpers/oidc/graphql/fetch-oidc-app.generated", () => ({ + getSdk: () => ({ + FetchOIDCApp, + }), +})); + +jest.mock("@/api/v1/oidc/authorize/graphql/fetch-nullifier.generated", () => ({ + getSdk: () => ({ + Nullifier, + }), +})); + +jest.mock("@/api/v1/oidc/authorize/graphql/upsert-nullifier.generated", () => ({ + getSdk: () => ({ + UpsertNullifier, + }), +})); + +jest.mock("@/api/helpers/oidc/graphql/insert-auth-code.generated", () => ({ + getSdk: () => ({ + InsertAuthCode, + }), +})); + +// Mock the verifyProof function +jest.mock("@/api/helpers/verify", () => ({ + verifyProof: jest.fn().mockResolvedValue({ error: null }), +})); + +beforeEach(async () => { + await global.RedisClient?.flushall(); + + // Mock OIDC app fetch + FetchOIDCApp.mockResolvedValue({ + app: [ + { + id: "app_112233445566778", + is_staging: false, + actions: [ + { + id: "action_staging_112233445566778", + action: "", + status: "active", + external_nullifier: + "0x1c75ff6366690115808bd58e4c6e3342068088703dffa0a0ee07f55892bb10bd", + redirects: [ + { + redirect_uri: "https://example.com/cb", + }, + ], + }, + ], + }, + ], + }); + + // Mock nullifier operations + Nullifier.mockResolvedValue({ nullifier: [] }); + UpsertNullifier.mockResolvedValue({ + insert_nullifier_one: { nullifier_hash: "0x123", id: "nil_123" }, + }); + + // Mock auth code insertion + InsertAuthCode.mockImplementation((args) => ({ + insert_auth_code_one: { auth_code: args.auth_code }, + })); +}); + +const VALID_REQUEST: Record = { + ...semaphoreProofParamsMock, + app_id: "app_1234", + scope: OIDCScopes.OpenID, + response_type: OIDCResponseType.Code, + redirect_uri: "https://example.com/cb", +}; + +describe("/api/v1/oidc/authorize [request validation]", () => { + test("validate required attributes", async () => { + const required_attributes = [ + "proof", + "nullifier_hash", + "merkle_root", + "verification_level", + "app_id", + "response_type", + "redirect_uri", + ]; + for (const attribute of required_attributes) { + const body = { ...VALID_REQUEST, [attribute]: undefined }; + delete body[attribute]; + const req = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify(body), + }, + ); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + code: "validation_error", + attribute, + detail: "This attribute is required.", + }); + } + }); + + test("openid scope is always required for OIDC requests", async () => { + const invalid_scopes = ["invalid", "profile%20email", undefined, ""]; + for (const scope of invalid_scopes) { + const req = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST, scope }), + }, + ); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + attribute: "scope", + detail: "The openid scope is always required.", + }); + } + }); + + test("invalid response_type throws an error", async () => { + const invalid_response_types = [ + "invalid", + "code%20invalid", + "code invalid", + ]; + for (const response_type of invalid_response_types) { + const req = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST, response_type }), + }, + ); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + attribute: "response_type", + code: OIDCErrorCodes.UnsupportedResponseType, + }); + } + }); + + test("validate redirect_uri", async () => { + const invalid_redirect_uris = [ + "http://example.com/cb", + "https://example.com/cb?query=string", + "https://example.com", + "https://evil.com", + ]; + for (const redirect_uri of invalid_redirect_uris) { + const req = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST, redirect_uri }), + }, + ); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + attribute: "redirect_uri", + detail: "Invalid redirect URI.", + code: OIDCErrorCodes.InvalidRedirectURI, + }); + } + }); +}); + +describe("/api/v1/oidc/authorize [authorization code flow]", () => { + test("returns an authorization code", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST }), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + code: expect.stringMatching(/^[a-f0-9]{16,30}$/), + }); + }); + + test("prevents replayed proofs", async () => { + const req1 = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST }), + }, + ); + + const req2 = new NextRequest( + "http://localhost:3000/api/v1/oidc/authorize", + { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST }), + }, + ); + + await POST(req1); + const response = await POST(req2); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toMatchObject({ + code: "invalid_proof", + attribute: "proof", + detail: "This proof has already been used. Please try again", + }); + }); +}); + +describe("/api/v1/oidc/authorize [implicit flow]", () => { + test("returns a valid token", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + body: JSON.stringify({ ...VALID_REQUEST, response_type: "id_token" }), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ id_token: expect.any(String) }); + + const jwt = data.id_token; + const publicKey = createPublicKey({ format: "jwk", key: publicJwk }); + const { protectedHeader, payload } = await jwtVerify(jwt, publicKey); + + expect(protectedHeader).toEqual({ + alg: "RS256", + kid: "kid_my_test_key", + typ: "JWT", + }); + + expect(payload).toEqual({ + iss: "https://id.worldcoin.org", + sub: semaphoreProofParamsMock.nullifier_hash, + jti: expect.any(String), + iat: expect.any(Number), + exp: expect.any(Number), + aud: "app_112233445566778", + scope: "openid", + "https://id.worldcoin.org/beta": { + likely_human: "strong", + credential_type: "orb", + warning: + "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", + }, + "https://id.worldcoin.org/v1": { + verification_level: "orb", + }, + nonce: semaphoreProofParamsMock.signal, + }); + + // Validate timestamps + const iatDiff = Math.abs(dayjs().diff(dayjs.unix(payload.iat!), "second")); + const oneHourFromNow = new Date().getTime() + 60 * 60 * 1000; + + const expDiff = Math.abs(oneHourFromNow / 1000 - payload.exp!); + expect(iatDiff).toBeLessThan(2); // 2 sec + expect(expDiff).toBeLessThan(2); // 2 sec + expect(payload.iat!.toString().length).toEqual(10); // timestamp in seconds has 10 digits + }); +}); + +describe("/api/v1/oidc/authorize [hybrid flow]", () => { + test("returns a valid token and authorization code", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + body: JSON.stringify({ + ...VALID_REQUEST, + response_type: "code id_token", + }), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + id_token: expect.any(String), + code: expect.stringMatching(/^[a-f0-9]{16,30}$/), + }); + + const jwt = data.id_token; + const publicKey = createPublicKey({ format: "jwk", key: publicJwk }); + const { protectedHeader, payload } = await jwtVerify(jwt, publicKey); + + expect(protectedHeader).toEqual({ + alg: "RS256", + kid: "kid_my_test_key", + typ: "JWT", + }); + + expect(payload).toEqual({ + iss: "https://id.worldcoin.org", + sub: semaphoreProofParamsMock.nullifier_hash, + jti: expect.any(String), + iat: expect.any(Number), + exp: expect.any(Number), + aud: "app_112233445566778", + scope: "openid", + "https://id.worldcoin.org/beta": { + likely_human: "strong", + credential_type: "orb", + warning: + "DEPRECATED and will be removed soon. Use `https://id.worldcoin.org/v1` instead.", + }, + "https://id.worldcoin.org/v1": { + verification_level: "orb", + }, + nonce: semaphoreProofParamsMock.signal, + }); + + // Validate timestamps + const iatDiff = Math.abs(dayjs().diff(dayjs.unix(payload.iat!), "second")); + const oneHourFromNow = new Date().getTime() + 60 * 60 * 1000; + + const expDiff = Math.abs(oneHourFromNow / 1000 - payload.exp!); + expect(iatDiff).toBeLessThan(2); // 2 sec + expect(expDiff).toBeLessThan(2); // 2 sec + expect(payload.iat!.toString().length).toEqual(10); // timestamp in seconds has 10 digits + }); +}); diff --git a/web/tests/api/v1/oidc/userinfo.test.ts b/web/tests/api/v1/oidc/userinfo.test.ts new file mode 100644 index 000000000..6c881f8c4 --- /dev/null +++ b/web/tests/api/v1/oidc/userinfo.test.ts @@ -0,0 +1,46 @@ +import { generateOIDCJWT } from "@/api/helpers/jwts"; +import { OIDCScopes } from "@/api/helpers/oidc"; +import { VerificationLevel } from "@worldcoin/idkit-core"; + +import { POST } from "@/api/v1/oidc/userinfo"; +import { NextRequest } from "next/server"; + +jest.mock("@/api/helpers/kms", () => + require("tests/api/__mocks__/kms.mock.ts"), +); + +jest.mock("@/api/helpers/jwks", () => + require("tests/api/__mocks__/jwks.mock.ts"), +); + +describe("/api/v1/oidc/userinfo", () => { + test("invalid jwt", async () => { + const jwt = await generateOIDCJWT({ + kid: "test-key", + nonce: "1234", + app_id: "app_1234", + kms_id: "test-kms-id", + nullifier_hash: "0x00000", + verification_level: VerificationLevel.Orb, + scope: [OIDCScopes.OpenID, OIDCScopes.Profile], + }); + // Ensure we're actually generating a JWT + expect(jwt).toMatch(/^[\w-]*\.[\w-]*\.[\w-]*$/); + + const req = new NextRequest("http://localhost:3000/api/v1/oidc/userinfo", { + method: "POST", + headers: { + authorization: `Bearer ${jwt}`, + }, + }); + + const response = await POST(req); + + expect(response.status).toBe(401); + const responseBody = await response.json(); + expect(responseBody).toMatchObject({ + attribute: "token", + code: "invalid_token", + }); + }); +}); diff --git a/web/tests/api/v1/oidc/validate.test.ts b/web/tests/api/v1/oidc/validate.test.ts new file mode 100644 index 000000000..12de15ab2 --- /dev/null +++ b/web/tests/api/v1/oidc/validate.test.ts @@ -0,0 +1,108 @@ +import { OIDCErrorCodes } from "@/api/helpers/oidc"; +import { POST } from "@/api/v1/oidc/validate"; +import { NextRequest } from "next/server"; + +jest.mock("@/api/helpers/graphql", () => ({ + getAPIServiceGraphqlClient: jest.fn(), +})); +const FetchOIDCApp = jest.fn(); +jest.mock("@/api/helpers/oidc/graphql/fetch-oidc-app.generated", () => ({ + getSdk: () => ({ + FetchOIDCApp, + }), +})); +beforeEach(() => { + FetchOIDCApp.mockResolvedValue({ + app: [ + { + id: "app_0123456789", + is_staging: true, + actions: [ + { + external_nullifier: "external_nullifier", + redirects: [ + { + redirect_uri: "https://example.com", + }, + ], + }, + ], + }, + ], + cache: [ + { + key: "staging.semaphore.wld.eth", + value: "0x000000000000000000000", + }, + ], + }); +}); + +describe("/api/v1/oidc/validate", () => { + test("can validate app and redirect_uri", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { + method: "POST", + body: JSON.stringify({ + app_id: "app_0123456789", + redirect_uri: "https://example.com", + }), + }); + + const response = await POST(req); + + expect(response.status).toBe(200); + const responseBody = await response.json(); + expect(responseBody).toMatchObject({ + app_id: "app_0123456789", + redirect_uri: "https://example.com", + }); + }); + + test("invalid app_id", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { + method: "POST", + body: JSON.stringify({ + app_id: "app_invalid", + redirect_uri: "https://example.com", + }), + }); + + FetchOIDCApp.mockResolvedValueOnce({ + app: [], + cache: [ + { + key: "staging.semaphore.wld.eth", + value: "0x000000000000000000000", + }, + ], + }); + + const response = await POST(req); + + expect(response.status).toBe(404); + const responseBody = await response.json(); + expect(responseBody).toMatchObject({ + attribute: "app_id", + code: "app_not_found", + }); + }); + + test("invalid redirect_uri", async () => { + const req = new NextRequest("http://localhost:3000/api/v1/oidc/validate", { + method: "POST", + body: JSON.stringify({ + app_id: "app_0123456789", + redirect_uri: "https://invalid.com", + }), + }); + + const response = await POST(req); + + expect(response.status).toBe(400); + const responseBody = await response.json(); + expect(responseBody).toMatchObject({ + code: OIDCErrorCodes.InvalidRedirectURI, + attribute: "redirect_uri", + }); + }); +}); diff --git a/web/tests/integration/jwks.test.ts b/web/tests/integration/jwks.test.ts new file mode 100644 index 000000000..e46ab4331 --- /dev/null +++ b/web/tests/integration/jwks.test.ts @@ -0,0 +1,92 @@ +import { fetchActiveJWK, generateJWK, retrieveJWK } from "@/api/helpers/jwks"; +import { createKMSKey, getKMSClient } from "@/api/helpers/kms"; +import { integrationDBClean, integrationDBExecuteQuery } from "./setup"; + +jest.mock("@/api/helpers/kms", () => { + return { + getKMSClient: jest.fn(), + createKMSKey: jest.fn(), + scheduleKeyDeletion: jest.fn(), + }; +}); + +jest.mock("@/api/helpers/kms", () => + require("tests/api/__mocks__/kms.mock.ts"), +); + +beforeEach(integrationDBClean); +describe("jwks management", () => { + it("can retrieve existing jwks", async () => { + const { rows } = await integrationDBExecuteQuery( + 'SELECT * FROM "public"."jwks" LIMIT 1;', + ); + + const jwk = await retrieveJWK(rows[0].id); + expect(jwk.kid).toEqual(rows[0].id); + expect(jwk.kms_id).toEqual(rows[0].kms_id); + }); + + it("throws error if the jwk is not found", async () => { + await expect(retrieveJWK("non-existing-jwk")).rejects.toThrowError( + "JWK not found.", + ); + }); + + it("fetches an active jwk", async () => { + const { rows } = await integrationDBExecuteQuery( + 'SELECT * FROM "public"."jwks" LIMIT 1;', + ); + + const jwk = await fetchActiveJWK(); + expect(jwk.kid).toEqual(rows[0].id); + }); + + it("does not rotate a jwk with more than 7 days to expire", async () => { + const { rows } = await integrationDBExecuteQuery( + 'SELECT * FROM "public"."jwks" WHERE "expires_at" > NOW() + INTERVAL \'7 days\' LIMIT 1;', + ); + + const jwk = await fetchActiveJWK(); + expect(jwk.kid).toEqual(rows[0].id); + }); + + it("rotates a jwk with less than 7 days to expire", async () => { + const { rows } = await integrationDBExecuteQuery( + 'UPDATE "public"."jwks" SET "expires_at" = NOW() + INTERVAL \'6 days\' WHERE "id" = (SELECT id FROM "public"."jwks" LIMIT 1) RETURNING "id";', + ); + + const jwk = await fetchActiveJWK(); + expect(jwk.kid).not.toEqual(rows[0].id); + }); + + it("can generate new kms keys", async () => { + // Mock the responses for KMS functions + (getKMSClient as jest.Mock).mockReturnValue(true); + (createKMSKey as jest.Mock).mockReturnValue({ + keyId: "da112a8b-023d-4eda-ae7d-33fde0a721b4", + publicKey: `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvzV3R48ve50etEd4BtryHzo1x1h1tC1poHkSXGzjXPIXmYvuLyZPCWfNzuH9YpXfuZRch1p3YrFRavSoQClb/kfAOou/nZXPyFdVlhzQzLp0EGB+/WEjA5Zj4J39EDdyToXmxsVNezzZJG66kfhz1VmBd18WGGAPDvw9PAdR2LpybKXl9VvwY5CFHazkadFy8Any+nKHpn3R3MxRHaeJV3EZDJfC+C46BCULkAS8EnZAtfdTJubIE71cNoOu/WmQupYsotk1XT3aN07ctvYuhyejiE+6bU3awre/kOumyjzb/7UWeIMvwxbFor3fEUPJa70xFfqPJUpFyj8NXlPE5wIDAQAB +-----END PUBLIC KEY-----`, + }); + + const result = await generateJWK(); + expect(result.keyId).toEqual("da112a8b-023d-4eda-ae7d-33fde0a721b4"); + }); + + it("throws error if kms client cannot be created", async () => { + // Mock the responses for KMS functions + (getKMSClient as jest.Mock).mockReturnValue(false); + + await expect(generateJWK()).rejects.toThrowError("KMS client not found."); + }); + + it("throws error if kms key generation fails", async () => { + // Mock the responses for KMS functions + (getKMSClient as jest.Mock).mockReturnValue(true); + (createKMSKey as jest.Mock).mockReturnValue({}); + + await expect(generateJWK()).rejects.toThrowError( + "Unable to create KMS key.", + ); + }); +}); diff --git a/web/tests/integration/oidc/authorize.test.ts b/web/tests/integration/oidc/authorize.test.ts new file mode 100644 index 000000000..d7a89f7b2 --- /dev/null +++ b/web/tests/integration/oidc/authorize.test.ts @@ -0,0 +1,151 @@ +import { OIDCErrorCodes } from "@/api/helpers/oidc"; +import { POST } from "@/api/v1/oidc/authorize"; +import { createHash } from "crypto"; +import { NextRequest } from "next/server"; +import { semaphoreProofParamsMock } from "tests/api/__mocks__/proof.mock"; +import { integrationDBClean, integrationDBExecuteQuery } from "../setup"; +import { testGetDefaultApp } from "../test-utils"; + +// Mock the verifyProof function +jest.mock("@/api/helpers/verify", () => ({ + verifyProof: jest.fn().mockResolvedValue({ error: null }), +})); + +beforeEach(async () => { + await integrationDBClean(); + await global.RedisClient?.flushall(); +}); + +const pkceChallenge = (code_verifier: string) => { + return createHash("sha256") + .update(code_verifier) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; + +const validParams = (app_id: string, pkce = false) => + ({ + // proof verification is mocked + ...semaphoreProofParamsMock, + app_id: app_id, + scope: "openid email", + response_type: "code", + redirect_uri: "http://localhost:3000/login", + state: "my_state", + ...(pkce + ? { + code_challenge: pkceChallenge("my_code_challenge"), + code_challenge_method: "S256", + } + : {}), + }) as Record; + +// TODO: Add additional test cases +describe("/api/v1/oidc/authorize", () => { + test("can get an auth code", async () => { + const dbQuery = await integrationDBExecuteQuery( + "SELECT * FROM app JOIN app_metadata ON app.id = app_metadata.app_id WHERE app_metadata.name = 'Sign In App' LIMIT 1;", + ); + const app_id = dbQuery.rows[0].app_id; + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validParams(app_id)), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + code: expect.stringMatching(/^[a-f0-9]{16,30}$/), + }); + }); + + test("`redirect_uri` is required", async () => { + const app_id = await testGetDefaultApp(); + + const params = validParams(app_id); + delete params.redirect_uri; + + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ + attribute: "redirect_uri", + code: "validation_error", + detail: "This attribute is required.", + }); + }); + + test("invalid `redirect_uri` is rejected", async () => { + const app_id = await testGetDefaultApp(); + + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...validParams(app_id), + redirect_uri: "https://example.com/invalid", + }), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ + attribute: "redirect_uri", + code: OIDCErrorCodes.InvalidRedirectURI, + detail: "Invalid redirect URI.", + app_id, + }); + }); + + test("can get an auth code with PKCE", async () => { + const dbQuery = await integrationDBExecuteQuery( + "SELECT * FROM app JOIN app_metadata ON app.id = app_metadata.app_id WHERE app_metadata.name = 'Sign In App' LIMIT 1;", + ); + const app_id = dbQuery.rows[0].app_id; + const req = new NextRequest("http://localhost:3000/api/v1/oidc/authorize", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(validParams(app_id, true)), + }); + + const response = await POST(req); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + code: expect.stringMatching(/^[a-f0-9]{16,30}$/), + }); + + const code = data.code; + + const { rows } = await integrationDBExecuteQuery( + `SELECT * FROM public.auth_code WHERE auth_code = '${code}' LIMIT 1;`, + ); + const { code_challenge, code_challenge_method } = rows[0]; + + expect(code_challenge_method).toEqual("S256"); + expect(code_challenge).toEqual(pkceChallenge("my_code_challenge")); + }); +}); diff --git a/web/tests/integration/oidc/token.test.ts b/web/tests/integration/oidc/token.test.ts new file mode 100644 index 000000000..b553a07f9 --- /dev/null +++ b/web/tests/integration/oidc/token.test.ts @@ -0,0 +1,749 @@ +import { POST } from "@/api/v1/oidc/token"; +import { createHash } from "crypto"; +import * as jose from "jose"; +import { NextRequest, NextResponse } from "next/server"; +import { publicJwk } from "tests/api/__mocks__/jwk"; +import { integrationDBClean, integrationDBExecuteQuery } from "../setup"; +import { setClientSecret, testGetSignInApp } from "../test-utils"; + +jest.mock("@/api/helpers/kms", () => + require("tests/api/__mocks__/kms.mock.ts"), +); + +const pkceChallenge = (code_verifier: string) => { + return createHash("sha256") + .update(code_verifier) + .digest("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +}; + +beforeEach(async () => await integrationDBClean()); + +describe("/api/v1/oidc/token", () => { + test("can exchange one-time auth code", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const data = await response.json(); + const { access_token, id_token, token_type, expires_in, scope } = data; + expect(access_token).toBeTruthy(); + expect(id_token).toEqual(access_token); + expect(token_type).toEqual("Bearer"); + expect(expires_in).toEqual(3600); + expect(scope).toEqual("openid email"); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + + // Make sure the proper error response is now sent + const request2 = new NextRequest( + "http://localhost:3000/api/v1/oidc/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }, + ); + + const response2 = (await POST(request2)) as NextResponse; + expect(response2.status).toBe(400); + const errorData = await response2.json(); + expect(errorData).toEqual( + expect.objectContaining({ + detail: "Invalid authorization code.", + code: "invalid_grant", + }), + ); + }); + + test("access_token is valid", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, verification_level, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "orb", + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token } = await response.json(); + + const { payload } = await jose.jwtVerify( + access_token, + await jose.importJWK(publicJwk, "RS256"), + { + issuer: process.env.JWT_ISSUER, + }, + ); + + expect(payload).toEqual( + expect.objectContaining({ + sub: "0x000000000000000111111111111", + aud: app_id, + iss: process.env.JWT_ISSUER, + exp: expect.any(Number), + iat: expect.any(Number), + jti: expect.any(String), + scope: "openid email", + email: "0x000000000000000111111111111@id.worldcoin.org", + "https://id.worldcoin.org/beta": expect.objectContaining({ + likely_human: "strong", + credential_type: "orb", + }), + "https://id.worldcoin.org/v1": { + verification_level: "orb", + }, + }), + ); + }); + + test("form-urlencoded with UTF-8 charset is accepted", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, verification_level, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "orb", + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token } = await response.json(); + + const { payload } = await jose.jwtVerify( + access_token, + await jose.importJWK(publicJwk, "RS256"), + { + issuer: process.env.JWT_ISSUER, + }, + ); + + expect(payload).toEqual( + expect.objectContaining({ + sub: "0x000000000000000111111111111", + aud: app_id, + iss: process.env.JWT_ISSUER, + exp: expect.any(Number), + iat: expect.any(Number), + jti: expect.any(String), + scope: "openid email", + email: "0x000000000000000111111111111@id.worldcoin.org", + "https://id.worldcoin.org/beta": expect.objectContaining({ + likely_human: "strong", + credential_type: "orb", + }), + "https://id.worldcoin.org/v1": { + verification_level: "orb", + }, + }), + ); + }); + + test("successfully validates PKCE", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code with PKCE + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + pkceChallenge("my_code_challenge"), + "S256", + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + code_verifier: "my_code_challenge", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token, id_token, token_type, expires_in, scope } = + await response.json(); + expect(access_token).toBeTruthy(); + expect(id_token).toEqual(access_token); + expect(token_type).toEqual("Bearer"); + expect(expires_in).toEqual(3600); + expect(scope).toEqual("openid email"); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("rejects invalid PKCE", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code with PKCE + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + pkceChallenge("my_code_challenge"), + "S256", + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + code_verifier: "invalid_code_challenge", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toEqual({ + attribute: "code_verifier", + code: "invalid_request", + detail: "Invalid code verifier.", + error: "invalid_request", + error_description: "Invalid code verifier.", + }); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("prevent PKCE downgrade", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code with PKCE + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + pkceChallenge("my_code_challenge"), + "S256", + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toEqual({ + attribute: "code_verifier", + code: "invalid_request", + detail: "Missing code verifier.", + error: "invalid_request", + error_description: "Missing code verifier.", + }); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("error when PKCE not expected", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code with PKCE + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + code_verifier: "my_code_challenge", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toEqual({ + code: "invalid_request", + error: "invalid_request", + attribute: "code_verifier", + detail: "Code verifier was not expected.", + error_description: "Code verifier was not expected.", + }); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("properly sets CORS headers", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code with PKCE + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, code_challenge, code_challenge_method, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + pkceChallenge("my_code_challenge"), + "S256", + "http://localhost:3000/login", + ], + ); + + const notPKCERequest = new NextRequest( + "http://localhost:3000/api/v1/oidc/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_secret, + client_id: app_id, + code: "83a313c5939399ba017d2381", + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }, + ); + + const notPKCEResponse = (await POST(notPKCERequest)) as NextResponse; + expect( + notPKCEResponse.headers.get("access-control-allow-origin"), + ).toBeNull(); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + code_verifier: "my_code_challenge", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.headers.get("access-control-allow-origin")).toEqual("*"); + }); + + test("successfully validates single redirect_uri", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token, id_token, token_type, expires_in, scope } = + await response.json(); + expect(access_token).toBeTruthy(); + expect(id_token).toEqual(access_token); + expect(token_type).toEqual("Bearer"); + expect(expires_in).toEqual(3600); + expect(scope).toEqual("openid email"); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("allows no redirect_uri when only one set", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token, id_token, token_type, expires_in, scope } = + await response.json(); + expect(access_token).toBeTruthy(); + expect(id_token).toEqual(access_token); + expect(token_type).toEqual("Bearer"); + expect(expires_in).toEqual(3600); + expect(scope).toEqual("openid email"); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); + + test("blocks no redirect_uri when multiple set", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // insert second redirect_uri + await integrationDBExecuteQuery( + "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", + [app_id, "http://localhost:3000/login2"], + ); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(400); + + const { code, detail, attribute } = await response.json(); + expect(code).toEqual("invalid_request"); + expect(detail).toEqual("Missing redirect URI."); + expect(attribute).toEqual("redirect_uri"); + }); + + test("blocks wrong redirect_uri when multiple set", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // insert second redirect_uri + await integrationDBExecuteQuery( + "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", + [app_id, "http://localhost:3000/login2"], + ); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login2", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(400); + + const { code, detail, attribute } = await response.json(); + expect(code).toEqual("invalid_request"); + expect(detail).toEqual("Invalid redirect URI."); + expect(attribute).toEqual("redirect_uri"); + }); + + test("accepts correct redirect_uri when multiple set", async () => { + const app_id = await testGetSignInApp(); + const { client_secret } = await setClientSecret(app_id); + + // insert second redirect_uri + await integrationDBExecuteQuery( + "INSERT INTO redirect (action_id, redirect_uri) VALUES ((SELECT id FROM action WHERE app_id = $1 AND action = '') , $2)", + [app_id, "http://localhost:3000/login2"], + ); + + // Insert a valid auth code + await integrationDBExecuteQuery( + "INSERT INTO auth_code (app_id, auth_code, expires_at, nullifier_hash, scope, redirect_uri) VALUES ($1, $2, $3, $4, $5, $6)", + [ + app_id, + "83a313c5939399ba017d2381", + "2030-09-01T00:00:00.000Z", + "0x000000000000000111111111111", + '["openid", "email"]', + "http://localhost:3000/login", + ], + ); + + const request = new NextRequest("http://localhost:3000/api/v1/oidc/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + code: "83a313c5939399ba017d2381", + client_id: app_id, + client_secret, + grant_type: "authorization_code", + redirect_uri: "http://localhost:3000/login", + }), + }); + + const response = (await POST(request)) as NextResponse; + expect(response.status).toBe(200); + + const { access_token, id_token, token_type, expires_in, scope } = + await response.json(); + expect(access_token).toBeTruthy(); + expect(id_token).toEqual(access_token); + expect(token_type).toEqual("Bearer"); + expect(expires_in).toEqual(3600); + expect(scope).toEqual("openid email"); + + // Verify that the auth code is deleted + const result = await integrationDBExecuteQuery( + "SELECT id FROM auth_code WHERE app_id = $1 AND auth_code = $2", + [app_id, "83a313c5939399ba017d2381"], + ); + expect(result.rowCount).toEqual(0); + }); +}); diff --git a/web/tests/unit/check-flow-type.test.ts b/web/tests/unit/check-flow-type.test.ts new file mode 100644 index 000000000..b58fbb226 --- /dev/null +++ b/web/tests/unit/check-flow-type.test.ts @@ -0,0 +1,59 @@ +import { checkFlowType } from "@/api/helpers/oidc"; +import { OIDCFlowType } from "@/lib/types"; + +describe("Check flow type", () => { + test("Detects authorization code flow", () => { + const validResponseTypes = ["code"]; + + validResponseTypes.forEach((responseType) => { + const responseTypes = responseType.split(" "); + expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.AuthorizationCode); + }); + }); + + test("Detects implicit flow", () => { + const validResponseTypes = ["token id_token", "id_token token", "id_token"]; + validResponseTypes.forEach((responseType) => { + const responseTypes = responseType.split(" "); + expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Implicit); + }); + }); + + test("Detects hybrid flow", () => { + const validResponseTypes = [ + "code id_token", + "id_token code", + "code token", + "token code", + "code id_token token", + "code token id_token", + "token code id_token", + "token id_token code", + "id_token code token", + "id_token token code", + ]; + + validResponseTypes.forEach((responseType) => { + const responseTypes = responseType.split(" "); + expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Hybrid); + }); + }); + + test("Detects `token` flow", () => { + const validResponseTypes = ["token"]; + + validResponseTypes.forEach((responseType) => { + const responseTypes = responseType.split(" "); + expect(checkFlowType(responseTypes)).toBe(OIDCFlowType.Token); + }); + }); + + test("Detects invalid flow", () => { + const invalidResponseTypes = ["value", "value1 value2"]; + + invalidResponseTypes.forEach((responseType) => { + const responseTypes = responseType.split(" "); + expect(checkFlowType(responseTypes)).toBe(null); + }); + }); +}); From 44a54874224b84f57d0c9a66f6cb945a02c2021d Mon Sep 17 00:00:00 2001 From: 0x1 <13666360+0x1@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:18:59 -0400 Subject: [PATCH 6/6] fix: restore missing delete-jwks route to keep Hasura cron job functional --- web/app/api/%5Fdelete-jwks/route.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/app/api/%5Fdelete-jwks/route.ts diff --git a/web/app/api/%5Fdelete-jwks/route.ts b/web/app/api/%5Fdelete-jwks/route.ts new file mode 100644 index 000000000..57964ae74 --- /dev/null +++ b/web/app/api/%5Fdelete-jwks/route.ts @@ -0,0 +1 @@ +export { POST } from "@/api/_delete-jwks";