From fbf1fc851706b7a11d088ae47a99746cf15d0e71 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 5 Aug 2026 13:00:15 -0400 Subject: [PATCH 1/2] feat: has({ oauth_scope: 'scope' }) --- .changeset/oauth-scope-authorization.md | 7 ++ integration/tests/hono/machine.test.ts | 10 ++- .../src/tokens/__tests__/authObjects.test.ts | 20 ++++- .../src/tokens/__tests__/getAuth.test-d.ts | 4 + packages/backend/src/tokens/authObjects.ts | 15 +++- .../__tests__/getAuthDataFromRequest.test.ts | 80 +++++++++++++++++ .../src/server/data/getAuthDataFromRequest.ts | 9 +- .../src/__tests__/authorization.spec.ts | 87 ++++++++++++++++++- packages/shared/src/authorization.ts | 64 +++++++++++++- packages/shared/src/types/session.ts | 34 ++++++++ 10 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 .changeset/oauth-scope-authorization.md diff --git a/.changeset/oauth-scope-authorization.md b/.changeset/oauth-scope-authorization.md new file mode 100644 index 00000000000..ea72a116826 --- /dev/null +++ b/.changeset/oauth-scope-authorization.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +--- + +OAuth access-token auth objects now support `has({ oauth_scope: 'scope' })` to authorize against the exact scopes granted in the token. diff --git a/integration/tests/hono/machine.test.ts b/integration/tests/hono/machine.test.ts index 16d0fddd9e6..47463a0d417 100644 --- a/integration/tests/hono/machine.test.ts +++ b/integration/tests/hono/machine.test.ts @@ -103,13 +103,17 @@ app.get('/m2m', c => { .addFile('src/server/app.ts', () => createAppFile(` app.get('/oauth-verify', c => { - const { userId, tokenType } = getAuth(c, { acceptsToken: 'oauth_token' }); + const auth = getAuth(c, { acceptsToken: 'oauth_token' }); - if (!userId) { + if (!auth.userId) { return c.text('Unauthorized', 401); } - return c.json({ userId, tokenType }); + if (!auth.has({ oauth_scope: 'profile' })) { + return c.text('Forbidden', 403); + } + + return c.json({ userId: auth.userId, tokenType: auth.tokenType }); }); app.get('/oauth/callback', c => { diff --git a/packages/backend/src/tokens/__tests__/authObjects.test.ts b/packages/backend/src/tokens/__tests__/authObjects.test.ts index fde4ec53c75..4916be05985 100644 --- a/packages/backend/src/tokens/__tests__/authObjects.test.ts +++ b/packages/backend/src/tokens/__tests__/authObjects.test.ts @@ -358,8 +358,14 @@ describe('authenticatedMachineObject', () => { expect(retrievedToken).toBe(token); }); - it('has() always returns false', () => { + it('has() checks exact OAuth scopes and denies other authorization checks', () => { const authObject = authenticatedMachineObject('oauth_token', token, verificationResult, debugData); + expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(true); + expect(authObject.has({ oauth_scope: 'write:bar' })).toBe(true); + expect(authObject.has({ oauth_scope: 'read:baz' })).toBe(false); + expect(authObject.has({ oauth_scope: 'READ:FOO' })).toBe(false); + expect(authObject.has({ oauth_scope: '' })).toBe(false); + expect(authObject.has({ role: 'org:admin' })).toBe(false); expect(authObject.has({})).toBe(false); }); @@ -415,6 +421,18 @@ describe('unauthenticatedMachineObject', () => { expect(authObject.has({})).toBe(false); }); + it('accepts OAuth scope checks and returns false for unauthenticated OAuth tokens', () => { + const authObject = unauthenticatedMachineObject('oauth_token'); + expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(false); + }); + + it('does not expose OAuth scope checks on other machine token types', () => { + const authObject = unauthenticatedMachineObject('api_key'); + + // @ts-expect-error OAuth scope checks are only available for OAuth tokens. + expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(false); + }); + it('getToken always returns null ', async () => { const authObject = unauthenticatedMachineObject('m2m_token'); const retrievedToken = await authObject.getToken(); diff --git a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts index 79f03809904..69e589a9cc5 100644 --- a/packages/backend/src/tokens/__tests__/getAuth.test-d.ts +++ b/packages/backend/src/tokens/__tests__/getAuth.test-d.ts @@ -42,10 +42,14 @@ describe('getAuth() or auth() with request parameter', () => { expectTypeOf(auth).toExtend(); } else if (auth.tokenType === 'api_key') { expectTypeOf(auth).toExtend>(); + // @ts-expect-error OAuth scope checks are only available for OAuth tokens. + auth.has({ oauth_scope: 'profile' }); } else if (auth.tokenType === 'm2m_token') { expectTypeOf(auth).toExtend>(); } else if (auth.tokenType === 'oauth_token') { expectTypeOf(auth).toExtend>(); + auth.has({ oauth_scope: 'profile' }); + auth.has({ role: 'org:admin' }); } }); }); diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 44391b388da..48e3bcd67a7 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,6 +1,7 @@ -import { createCheckAuthorization } from '@clerk/shared/authorization'; +import { createCheckAuthorization, createCheckAuthorizationFromOAuthScopes } from '@clerk/shared/authorization'; import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser'; import type { + CheckAuthorizationFromOAuthScopes, CheckAuthorizationFromSessionClaims, Jwt, JwtPayload, @@ -31,6 +32,10 @@ type AuthObjectDebug = () => AuthObjectDebugData; type Claims = Record; +type CheckAuthorizationFromMachineToken = T extends typeof TokenType.OAuthToken + ? CheckAuthorizationFromOAuthScopes + : CheckAuthorizationFromSessionClaims; + /** * @internal */ @@ -119,7 +124,7 @@ export type AuthenticatedMachineObject Promise; - has: CheckAuthorizationFromSessionClaims; + has: CheckAuthorizationFromMachineToken; debug: AuthObjectDebug; tokenType: T; isAuthenticated: true; @@ -140,7 +145,7 @@ export type UnauthenticatedMachineObject Promise; - has: CheckAuthorizationFromSessionClaims; + has: CheckAuthorizationFromMachineToken; debug: AuthObjectDebug; tokenType: T; isAuthenticated: false; @@ -310,6 +315,10 @@ export function authenticatedMachineObject( scopes: result.scopes, userId: result.subject, clientId: result.clientId, + has: createCheckAuthorizationFromOAuthScopes({ + userId: result.subject, + oauthScopes: result.scopes, + }), } as unknown as AuthenticatedMachineObject; } default: diff --git a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts index 1a49c6b6c13..7ffcab55b91 100644 --- a/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts +++ b/packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts @@ -166,6 +166,86 @@ describe('getAuthDataFromRequest', () => { expect(auth.isAuthenticated).toBe(true); }); + it('reconstructs OAuth scope authorization from encrypted machine auth data', () => { + const machineAuthObject = createMockMachineAuthObject({ + tokenType: 'oauth_token', + id: 'oat_id123', + subject: 'user_12345', + userId: 'user_12345', + clientId: 'client_12345', + scopes: ['profile', 'email'], + isAuthenticated: true, + }); + + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer oat_secret123', + }), + machineAuthObject, + }); + + const auth = getAuthDataFromRequest(req, { acceptsToken: 'oauth_token' }); + + expect(auth.tokenType).toBe('oauth_token'); + expect(auth.isAuthenticated).toBe(true); + if (auth.tokenType !== 'oauth_token') { + throw new Error('Expected an OAuth auth object'); + } + expect(auth.has({ oauth_scope: 'profile' })).toBe(true); + expect(auth.has({ oauth_scope: 'email' })).toBe(true); + expect(auth.has({ oauth_scope: 'openid' })).toBe(false); + expect(auth.has({ oauth_scope: 'PROFILE' })).toBe(false); + }); + + it('keeps OAuth scope authorization disabled for unauthenticated reconstructed auth data', () => { + const machineAuthObject = createMockMachineAuthObject({ + tokenType: 'oauth_token', + isAuthenticated: false, + }); + + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer oat_invalid', + }), + machineAuthObject, + }); + + const auth = getAuthDataFromRequest(req, { acceptsToken: 'oauth_token' }); + + expect(auth.tokenType).toBe('oauth_token'); + expect(auth.isAuthenticated).toBe(false); + if (auth.tokenType !== 'oauth_token') { + throw new Error('Expected an OAuth auth object'); + } + expect(auth.has({ oauth_scope: 'profile' })).toBe(false); + }); + + it('keeps non-OAuth machine token authorization disabled after reconstruction', () => { + const machineAuthObject = createMockMachineAuthObject({ + tokenType: 'api_key', + id: 'ak_id123', + subject: 'user_12345', + scopes: ['profile'], + isAuthenticated: true, + }); + + const req = mockRequest({ + url: '/api/protected', + headers: new Headers({ + [constants.Headers.Authorization]: 'Bearer ak_secret123', + }), + machineAuthObject, + }); + + const auth = getAuthDataFromRequest(req, { acceptsToken: 'api_key' }); + + expect(auth.tokenType).toBe('api_key'); + expect(auth.isAuthenticated).toBe(true); + expect(auth.has({})).toBe(false); + }); + it('returns authenticated object when token type exists in acceptsToken array', () => { const machineAuthObject = createMockMachineAuthObject({ tokenType: 'api_key', diff --git a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts index f62f6ab4798..adb2bf61273 100644 --- a/packages/nextjs/src/server/data/getAuthDataFromRequest.ts +++ b/packages/nextjs/src/server/data/getAuthDataFromRequest.ts @@ -17,6 +17,7 @@ import { TokenType, } from '@clerk/backend/internal'; import { decodeJwt } from '@clerk/backend/jwt'; +import { createCheckAuthorizationFromOAuthScopes } from '@clerk/shared/authorization'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { LoggerNoCommit } from '../../utils/debugLogger'; @@ -171,7 +172,13 @@ const handleMachineToken = ( return { ...authObject, getToken: () => (authObject.isAuthenticated ? Promise.resolve(bearerToken) : Promise.resolve(null)), - has: () => false, + has: + authObject.tokenType === TokenType.OAuthToken + ? createCheckAuthorizationFromOAuthScopes({ + userId: authObject.userId, + oauthScopes: authObject.scopes, + }) + : () => false, } as MachineAuthObject; } diff --git a/packages/shared/src/__tests__/authorization.spec.ts b/packages/shared/src/__tests__/authorization.spec.ts index 42e25c37194..7a58cf45ce0 100644 --- a/packages/shared/src/__tests__/authorization.spec.ts +++ b/packages/shared/src/__tests__/authorization.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createCheckAuthorization, splitByScope } from '../authorization'; +import { createCheckAuthorization, createCheckAuthorizationFromOAuthScopes, splitByScope } from '../authorization'; describe('createCheckAuthorization', () => { it('correctly parses features', () => { @@ -377,6 +377,91 @@ describe('createCheckAuthorization', () => { }); }); +describe('createCheckAuthorizationFromOAuthScopes', () => { + it('authorizes an exact, case-sensitive scope match', () => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: ['profile', 'Email'], + }); + + expect(has({ oauth_scope: 'profile' })).toBe(true); + expect(has({ oauth_scope: 'Email' })).toBe(true); + expect(has({ oauth_scope: 'PROFILE' })).toBe(false); + expect(has({ oauth_scope: 'email' })).toBe(false); + expect(has({ oauth_scope: 'missing' })).toBe(false); + }); + + it('fails closed for invalid requested scopes', () => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: ['profile'], + }); + + expect(has({ oauth_scope: '' })).toBe(false); + expect(has({ oauth_scope: null } as any)).toBe(false); + expect(has({ oauth_scope: 123 } as any)).toBe(false); + expect(has({ oauth_scope: undefined } as any)).toBe(false); + }); + + it.each([undefined, null, {}, ['profile', 123], ['profile', '']])( + 'fails closed for malformed granted scopes: %j', + oauthScopes => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: oauthScopes as any, + }); + + expect(has({ oauth_scope: 'profile' })).toBe(false); + }, + ); + + it.each([undefined, null, ''])('fails when userId is missing: %j', userId => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId, + oauthScopes: ['profile'], + }); + + expect(has({ oauth_scope: 'profile' })).toBe(false); + }); + + it('accepts previous session authorization shapes but denies them', () => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: ['profile'], + }); + + expect(has({ role: 'org:admin' })).toBe(false); + expect(has({ permission: 'org:read' })).toBe(false); + expect(has({ feature: 'user:premium' })).toBe(false); + expect(has({ plan: 'user:pro' })).toBe(false); + expect(has({ reverification: 'strict' })).toBe(false); + }); + + it.each([ + { role: 'org:admin' }, + { permission: 'org:read' }, + { feature: 'user:premium' }, + { plan: 'user:pro' }, + { reverification: 'strict' }, + ])('denies a valid OAuth scope mixed with another recognized dimension: %j', params => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: ['profile'], + }); + + expect(has({ oauth_scope: 'profile', ...params } as any)).toBe(false); + }); + + it('fails when no authorization dimension is requested', () => { + const has = createCheckAuthorizationFromOAuthScopes({ + userId: 'user_123', + oauthScopes: ['profile'], + }); + + expect(has({})).toBe(false); + }); +}); + describe('splitByScope', () => { it('correctly splits features by scope', () => { const { org, user } = splitByScope('o:reservations,u:dashboard'); diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index 3f5c4b75d91..7003a2a8815 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -1,5 +1,6 @@ import type { ActClaim, + CheckAuthorizationFromOAuthScopes, CheckAuthorizationWithCustomPermissions, GetToken, JwtPayload, @@ -25,6 +26,11 @@ type AuthorizationOptions = { plans: string | null | undefined; }; +type OAuthAuthorizationOptions = { + userId: string | null | undefined; + oauthScopes: string[] | null | undefined; +}; + // Internal verdict for each authorization dimension. // pass = caller asked, the dimension is satisfied // fail = caller asked, the dimension is not satisfied (includes "data missing" - fail closed) @@ -48,6 +54,11 @@ type CheckReverificationAuthorization = ( { factorVerificationAge }: AuthorizationOptions, ) => CheckResult; +type CheckOAuthScopeAuthorization = ( + params: { oauth_scope?: string }, + options: Pick, +) => CheckResult; + const TYPES_TO_OBJECTS: TypesToConfig = { strict_mfa: { afterMinutes: 10, @@ -327,6 +338,24 @@ const checkReverificationAuthorization: CheckReverificationAuthorization = (para } }; +const checkOAuthScopeAuthorization: CheckOAuthScopeAuthorization = (params, { oauthScopes }) => { + const oauthScopeAsked = params.oauth_scope !== undefined; + + if (!oauthScopeAsked) { + return 'skip'; + } + + if (typeof params.oauth_scope !== 'string' || !params.oauth_scope) { + return 'fail'; + } + + if (!Array.isArray(oauthScopes) || oauthScopes.some(scope => typeof scope !== 'string' || !scope)) { + return 'fail'; + } + + return oauthScopes.includes(params.oauth_scope) ? 'pass' : 'fail'; +}; + // At least one dimension must have passed, and every non-skip result must be a pass. // This is an AND across asked dimensions with a fail-closed default: if a helper ever // returns anything other than 'pass' or 'skip' (a typo, off-type, or future variant), @@ -355,6 +384,33 @@ const createCheckAuthorization = (options: AuthorizationOptions): CheckAuthoriza }; }; +const createCheckAuthorizationFromOAuthScopes = ( + options: OAuthAuthorizationOptions, +): CheckAuthorizationFromOAuthScopes => { + const unavailableSessionOptions: AuthorizationOptions = { + userId: options.userId, + orgId: null, + orgRole: null, + orgPermissions: null, + factorVerificationAge: null, + features: null, + plans: null, + }; + + return params => { + if (!options.userId) { + return false; + } + + return combine([ + checkOrgAuthorization(params, unavailableSessionOptions), + checkBillingAuthorization(params, unavailableSessionOptions), + checkReverificationAuthorization(params, unavailableSessionOptions), + checkOAuthScopeAuthorization(params, options), + ]); + }; +}; + type AuthStateOptions = { authObject: { userId?: string | null; @@ -481,4 +537,10 @@ const resolveAuthState = ({ } }; -export { createCheckAuthorization, resolveAuthState, splitByScope, validateReverificationConfig }; +export { + createCheckAuthorization, + createCheckAuthorizationFromOAuthScopes, + resolveAuthState, + splitByScope, + validateReverificationConfig, +}; diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 878fd6e8ecb..03a141fcfb8 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -152,6 +152,15 @@ export type CheckAuthorizationFromSessionClaims =

, ) => boolean; +/** + * Type guard for authorization checks using OAuth access token scopes. + * Session authorization parameter shapes remain accepted for backwards compatibility, + * but cannot be authorized from OAuth scope data. + */ +export type CheckAuthorizationFromOAuthScopes =

( + isAuthorizedParams: CheckAuthorizationParamsFromOAuthScopes

, +) => boolean; + /** * @interface */ @@ -195,6 +204,20 @@ export type CheckAuthorizationParamsFromSessionClaims

; +type CheckAuthorizationParamsFromOAuthScopes

= + | (CheckAuthorizationParamsFromSessionClaims

& { oauth_scope?: never }) + | { + /** + * The OAuth access token scope to check for. + */ + oauth_scope: string; + role?: never; + permission?: never; + feature?: never; + plan?: never; + reverification?: never; + }; + /** * The `Session` object is an abstraction over an HTTP session. It models the period of information exchange between a user and the server. * @@ -289,6 +312,7 @@ export interface SessionResource extends ClerkResource { getToken: GetToken; /** * Checks if the user is [authorized for the specified Role, Permission, Feature, or Plan](https://clerk.com/docs/guides/secure/authorization-checks) or requires the user to [reverify their credentials](https://clerk.com/docs/guides/secure/reverification) if their last verification is older than allowed. + * * @skipParametersSection */ checkAuthorization: CheckAuthorization; @@ -306,12 +330,15 @@ export interface SessionResource extends ClerkResource { updatedAt: Date; /** * Initiates the reverification flow. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. */ startVerification: (params: SessionVerifyCreateParams) => Promise; /** * Initiates the [first factor verification](!first-factor-verification) process. This is a required step to complete a reverification flow when using a preparable factor. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ prepareFirstFactorVerification: ( @@ -319,7 +346,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Attempts to complete the [first factor verification](!first-factor-verification) process. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ attemptFirstFactorVerification: ( @@ -327,7 +356,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Initiates the [second factor verification](!second-factor-verification) process. This is a required step to complete a reverification flow when using a preparable factor. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ prepareSecondFactorVerification: ( @@ -335,7 +366,9 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Attempts to complete the [second factor verification](!second-factor-verification) process. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. + * * @skipParametersSection */ attemptSecondFactorVerification: ( @@ -343,6 +376,7 @@ export interface SessionResource extends ClerkResource { ) => Promise; /** * Initiates a verification flow using passkeys. + * * @returns A [`SessionVerification`](https://clerk.com/docs/reference/types/session-verification) instance with its status and supported factors. */ verifyWithPasskey: () => Promise; From e9682287caf039ed6a49b48f08bd9d002c265878 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Wed, 5 Aug 2026 13:01:55 -0400 Subject: [PATCH 2/2] chore: example app for testing has({ oauth_scope }) --- examples/oauth-scope-hono/.env.example | 26 ++ examples/oauth-scope-hono/.gitignore | 1 + examples/oauth-scope-hono/README.md | 93 +++++ examples/oauth-scope-hono/package.json | 28 ++ examples/oauth-scope-hono/pnpm-lock.yaml | 364 ++++++++++++++++++ examples/oauth-scope-hono/pnpm-workspace.yaml | 2 + examples/oauth-scope-hono/src/client.ts | 298 ++++++++++++++ examples/oauth-scope-hono/src/server.ts | 76 ++++ examples/oauth-scope-hono/tsconfig.json | 13 + 9 files changed, 901 insertions(+) create mode 100644 examples/oauth-scope-hono/.env.example create mode 100644 examples/oauth-scope-hono/.gitignore create mode 100644 examples/oauth-scope-hono/README.md create mode 100644 examples/oauth-scope-hono/package.json create mode 100644 examples/oauth-scope-hono/pnpm-lock.yaml create mode 100644 examples/oauth-scope-hono/pnpm-workspace.yaml create mode 100644 examples/oauth-scope-hono/src/client.ts create mode 100644 examples/oauth-scope-hono/src/server.ts create mode 100644 examples/oauth-scope-hono/tsconfig.json diff --git a/examples/oauth-scope-hono/.env.example b/examples/oauth-scope-hono/.env.example new file mode 100644 index 00000000000..2e28caafe2a --- /dev/null +++ b/examples/oauth-scope-hono/.env.example @@ -0,0 +1,26 @@ +# Clerk resource server +CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +CLERK_API_URL=http://localhost:8002 +CLERK_API_VERSION=v1 +RESOURCE_SERVER_HOST=127.0.0.1 +RESOURCE_SERVER_PORT=8787 +RESOURCE_SERVER_URL=http://127.0.0.1:8787/protected +OAUTH_REQUIRED_SCOPE=profile + +# OAuth client +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_CLIENT_AUTH_METHOD=client_secret_post +OAUTH_REDIRECT_URI=http://127.0.0.1:8788/callback +OAUTH_SCOPE=profile email + +# Set both direct endpoints, or leave them blank and use issuer discovery. +OAUTH_AUTHORIZE_URL= +OAUTH_TOKEN_URL= +OAUTH_ISSUER_URL= +OAUTH_DISCOVERY_URL= + +OAUTH_OPEN_BROWSER=true +OAUTH_CALLBACK_TIMEOUT_MS=300000 +OAUTH_EXPECT_JWT_ACCESS_TOKEN=true diff --git a/examples/oauth-scope-hono/.gitignore b/examples/oauth-scope-hono/.gitignore new file mode 100644 index 00000000000..4c49bd78f1d --- /dev/null +++ b/examples/oauth-scope-hono/.gitignore @@ -0,0 +1 @@ +.env diff --git a/examples/oauth-scope-hono/README.md b/examples/oauth-scope-hono/README.md new file mode 100644 index 00000000000..ef67d21aee6 --- /dev/null +++ b/examples/oauth-scope-hono/README.md @@ -0,0 +1,93 @@ +# OAuth scope authorization with Hono + +This example runs two local programs: + +- a Hono resource server protected by Clerk's `clerkMiddleware()`; +- a PKCE OAuth client that opens Clerk's authorization page, receives the code on a loopback callback, exchanges it for an access token, and calls the protected route. + +The protected route accepts only `oauth_token` and authorizes with: + +```ts +auth.has({ oauth_scope: process.env.OAUTH_REQUIRED_SCOPE || 'profile' }); +``` + +## Configure the example app + +1. Create an OAuth application. Configure its redirect URI as `http://127.0.0.1:8788/callback` and allow at least `profile`. +1. Copy this instance's publishable key, secret key, and the OAuth application's client ID and client secret. +1. Copy the OAuth application's **Authorize URL** and **Token URL**. + +Then install and configure the example: + +```sh +# From the javascript repository root, install the SDK build dependencies first. +pnpm install --frozen-lockfile + +cd examples/oauth-scope-hono +cp .env.example .env +pnpm install --frozen-lockfile +``` + +Fill in at least: + +```dotenv +CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +OAUTH_CLIENT_ID=client_... +OAUTH_CLIENT_SECRET=... +OAUTH_AUTHORIZE_URL=https://.../oauth/authorize +OAUTH_TOKEN_URL=https://.../oauth/token +``` + +The example defaults `OAUTH_EXPECT_JWT_ACCESS_TOKEN=true`. New local instances default `oauth_jwt_access_tokens` to enabled; confirm that setting at `GET /v1/instance/oauth_application_settings` if the client reports an opaque token. Set the expectation to `false` only when intentionally testing opaque access tokens. + +For a public client, leave `OAUTH_CLIENT_SECRET` blank and set `OAUTH_CLIENT_AUTH_METHOD=none`. PKCE is used in every mode. Confidential Clerk OAuth applications normally use `client_secret_post`. + +Instead of direct endpoint URLs, you can leave `OAUTH_AUTHORIZE_URL` and `OAUTH_TOKEN_URL` blank and set `OAUTH_ISSUER_URL`. The client will use RFC 8414 authorization-server discovery. Set `OAUTH_DISCOVERY_URL` when the stack exposes metadata at a compatibility or proxy-specific URL. + +## Run + +Build the changed local SDK packages once: + +```sh +pnpm run sdk:build +``` + +Start the resource server: + +```sh +pnpm run server +``` + +In another terminal, run the OAuth client: + +```sh +pnpm run client +``` + +The client prints the authorization URL and opens it in the default browser. Sign in and approve consent. A successful run ends with a `200` response showing the granted scopes and subject. + +Useful client flags: + +```sh +pnpm client -- --no-open +pnpm client -- --print-token +``` + +`--no-open` only prints the URL. `--print-token` prints the access token and should only be used for local debugging. + +## Verify denial + +- Set `OAUTH_REQUIRED_SCOPE` to a scope not present in `OAUTH_SCOPE`, restart the server, and run the client again. Expect `403`. +- Remove the bearer token or send a non-OAuth token to `RESOURCE_SERVER_URL`. Expect `401`. +- Scope matching is exact and case-sensitive. + +## Contributing + +After SDK changes, rerun `pnpm sdk:build`. Validate the example itself with `pnpm typecheck`. + +In case local hostnames aren't resolving: + +```sh +export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" +``` diff --git a/examples/oauth-scope-hono/package.json b/examples/oauth-scope-hono/package.json new file mode 100644 index 00000000000..0f2a7e2b6b9 --- /dev/null +++ b/examples/oauth-scope-hono/package.json @@ -0,0 +1,28 @@ +{ + "name": "@clerk/example-oauth-scope-hono", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "client": "tsx src/client.ts", + "sdk:build": "pnpm --dir ../.. --filter @clerk/hono... build", + "server": "tsx src/server.ts", + "server:watch": "tsx watch src/server.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clerk/hono": "link:../../packages/hono", + "@hono/node-server": "1.19.17", + "dotenv": "^16.5.0", + "hono": "4.12.27" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "tsx": "^4.20.6", + "typescript": "^6.0.3" + }, + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=24.15.0" + } +} diff --git a/examples/oauth-scope-hono/pnpm-lock.yaml b/examples/oauth-scope-hono/pnpm-lock.yaml new file mode 100644 index 00000000000..55f7ddc6a31 --- /dev/null +++ b/examples/oauth-scope-hono/pnpm-lock.yaml @@ -0,0 +1,364 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@clerk/hono': + specifier: link:../../packages/hono + version: link:../../packages/hono + '@hono/node-server': + specifier: 1.19.17 + version: 1.19.17(hono@4.12.27) + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + hono: + specifier: 4.12.27 + version: 4.12.27 + devDependencies: + '@types/node': + specifier: ^22.19.17 + version: 22.20.1 + tsx: + specifier: ^4.20.6 + version: 4.23.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@hono/node-server@1.19.17(hono@4.12.27)': + dependencies: + hono: 4.12.27 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + dotenv@16.6.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + hono@4.12.27: {} + + tsx@4.23.5: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} diff --git a/examples/oauth-scope-hono/pnpm-workspace.yaml b/examples/oauth-scope-hono/pnpm-workspace.yaml new file mode 100644 index 00000000000..d05a7e7dc84 --- /dev/null +++ b/examples/oauth-scope-hono/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - . diff --git a/examples/oauth-scope-hono/src/client.ts b/examples/oauth-scope-hono/src/client.ts new file mode 100644 index 00000000000..5f59fe5adc9 --- /dev/null +++ b/examples/oauth-scope-hono/src/client.ts @@ -0,0 +1,298 @@ +import 'dotenv/config'; + +import { spawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { createServer, type Server, type ServerResponse } from 'node:http'; + +type OAuthEndpoints = { + authorizationEndpoint: string; + tokenEndpoint: string; +}; + +type CallbackServer = { + authorizationCode: Promise; + close: () => Promise; +}; + +const requiredEnv = (name: string): string => { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing ${name}. Copy .env.example to .env and configure it.`); + } + return value; +}; + +const optionalEnv = (name: string): string | undefined => process.env[name]?.trim() || undefined; + +const metadataUrlForIssuer = (issuer: string): string => { + const url = new URL(issuer); + const issuerPath = url.pathname.replace(/\/+$/, ''); + url.pathname = `/.well-known/oauth-authorization-server${issuerPath === '/' ? '' : issuerPath}`; + url.search = ''; + url.hash = ''; + return url.toString(); +}; + +const resolveOAuthEndpoints = async (): Promise => { + const configuredAuthorizationEndpoint = optionalEnv('OAUTH_AUTHORIZE_URL'); + const configuredTokenEndpoint = optionalEnv('OAUTH_TOKEN_URL'); + + if (configuredAuthorizationEndpoint && configuredTokenEndpoint) { + return { + authorizationEndpoint: new URL(configuredAuthorizationEndpoint).toString(), + tokenEndpoint: new URL(configuredTokenEndpoint).toString(), + }; + } + + const issuer = requiredEnv('OAUTH_ISSUER_URL'); + const metadataUrl = optionalEnv('OAUTH_DISCOVERY_URL') || metadataUrlForIssuer(issuer); + const response = await fetch(metadataUrl, { headers: { Accept: 'application/json' } }); + + if (!response.ok) { + throw new Error(`OAuth metadata request failed (${response.status}) at ${metadataUrl}`); + } + + const metadata = (await response.json()) as Record; + const discoveredAuthorizationEndpoint = metadata.authorization_endpoint; + const discoveredTokenEndpoint = metadata.token_endpoint; + const authorizationEndpoint = configuredAuthorizationEndpoint || discoveredAuthorizationEndpoint; + const tokenEndpoint = configuredTokenEndpoint || discoveredTokenEndpoint; + + if (typeof authorizationEndpoint !== 'string' || typeof tokenEndpoint !== 'string') { + throw new Error('OAuth metadata must contain authorization_endpoint and token_endpoint.'); + } + + return { + authorizationEndpoint: new URL(authorizationEndpoint).toString(), + tokenEndpoint: new URL(tokenEndpoint).toString(), + }; +}; + +const isLoopbackHostname = (hostname: string): boolean => + hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]'; + +const writeCallbackResponse = (response: ServerResponse, status: number, message: string): void => { + response.writeHead(status, { + 'Cache-Control': 'no-store', + Connection: 'close', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end(message); +}; + +const startCallbackServer = async (redirectUri: string, expectedState: string): Promise => { + const callbackUrl = new URL(redirectUri); + if (callbackUrl.protocol !== 'http:' || !isLoopbackHostname(callbackUrl.hostname) || !callbackUrl.port) { + throw new Error('OAUTH_REDIRECT_URI must be an http loopback URL with an explicit port.'); + } + + let resolveCode!: (code: string) => void; + let rejectCode!: (error: Error) => void; + const authorizationCode = new Promise((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + + const server = createServer((request, response) => { + const requestUrl = new URL(request.url || '/', callbackUrl.origin); + if (requestUrl.pathname !== callbackUrl.pathname) { + writeCallbackResponse(response, 404, 'Not found'); + return; + } + + if (requestUrl.searchParams.get('state') !== expectedState) { + writeCallbackResponse(response, 400, 'OAuth state mismatch. Return to the terminal and retry.'); + return; + } + + const oauthError = requestUrl.searchParams.get('error'); + if (oauthError) { + const description = requestUrl.searchParams.get('error_description'); + writeCallbackResponse(response, 400, 'OAuth authorization failed. Return to the terminal.'); + rejectCode(new Error(`${oauthError}${description ? `: ${description}` : ''}`)); + return; + } + + const code = requestUrl.searchParams.get('code'); + if (!code) { + writeCallbackResponse(response, 400, 'Missing OAuth authorization code.'); + return; + } + + writeCallbackResponse(response, 200, 'Authorization complete. You can close this tab.'); + resolveCode(code); + }); + + const timeoutMs = Number(optionalEnv('OAUTH_CALLBACK_TIMEOUT_MS') || '300000'); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('OAUTH_CALLBACK_TIMEOUT_MS must be a positive number.'); + } + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(Number(callbackUrl.port), callbackUrl.hostname, () => { + server.off('error', reject); + resolve(); + }); + }); + + const timeout = setTimeout(() => rejectCode(new Error('Timed out waiting for the OAuth callback.')), timeoutMs); + timeout.unref(); + + return { + authorizationCode: authorizationCode.finally(() => clearTimeout(timeout)), + close: () => closeServer(server), + }; +}; + +const closeServer = async (server: Server): Promise => { + if (!server.listening) { + return; + } + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); +}; + +const openBrowser = (url: string): void => { + const command = + process.platform === 'darwin' + ? { executable: 'open', args: [url] } + : process.platform === 'win32' + ? { executable: 'cmd', args: ['/c', 'start', '', url] } + : { executable: 'xdg-open', args: [url] }; + + const child = spawn(command.executable, command.args, { detached: true, stdio: 'ignore' }); + child.once('error', () => { + console.error('Could not open a browser automatically. Open the printed URL manually.'); + }); + child.unref(); +}; + +const exchangeCode = async ( + tokenEndpoint: string, + code: string, + codeVerifier: string, +): Promise & { access_token: string }> => { + const clientId = requiredEnv('OAUTH_CLIENT_ID'); + const clientSecret = optionalEnv('OAUTH_CLIENT_SECRET'); + const clientAuthMethod = optionalEnv('OAUTH_CLIENT_AUTH_METHOD') || (clientSecret ? 'client_secret_post' : 'none'); + const parameters = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: requiredEnv('OAUTH_REDIRECT_URI'), + client_id: clientId, + code_verifier: codeVerifier, + }); + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (clientAuthMethod === 'client_secret_post') { + if (!clientSecret) { + throw new Error('OAUTH_CLIENT_SECRET is required for client_secret_post.'); + } + parameters.set('client_secret', clientSecret); + } else if (clientAuthMethod === 'client_secret_basic') { + if (!clientSecret) { + throw new Error('OAUTH_CLIENT_SECRET is required for client_secret_basic.'); + } + headers.Authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + } else if (clientAuthMethod !== 'none') { + throw new Error('OAUTH_CLIENT_AUTH_METHOD must be client_secret_post, client_secret_basic, or none.'); + } + + const response = await fetch(tokenEndpoint, { method: 'POST', headers, body: parameters }); + const bodyText = await response.text(); + let body: Record; + try { + body = JSON.parse(bodyText) as Record; + } catch { + throw new Error(`Token endpoint returned non-JSON (${response.status}): ${bodyText}`); + } + + if (!response.ok || typeof body.access_token !== 'string') { + throw new Error(`Token exchange failed (${response.status}): ${JSON.stringify(body)}`); + } + + return body as Record & { access_token: string }; +}; + +const printResponseBody = (body: string): void => { + try { + console.log(JSON.stringify(JSON.parse(body), null, 2)); + } catch { + console.log(body); + } +}; + +const main = async (): Promise => { + const clientId = requiredEnv('OAUTH_CLIENT_ID'); + const redirectUri = requiredEnv('OAUTH_REDIRECT_URI'); + const requestedScope = requiredEnv('OAUTH_SCOPE'); + const resourceServerUrl = requiredEnv('RESOURCE_SERVER_URL'); + const { authorizationEndpoint, tokenEndpoint } = await resolveOAuthEndpoints(); + const state = randomBytes(24).toString('base64url'); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); + const authorizationUrl = new URL(authorizationEndpoint); + + authorizationUrl.searchParams.set('client_id', clientId); + authorizationUrl.searchParams.set('redirect_uri', redirectUri); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('scope', requestedScope); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + + const callbackServer = await startCallbackServer(redirectUri, state); + console.log(`\nAuthorize this client:\n${authorizationUrl.toString()}\n`); + + const shouldOpenBrowser = !process.argv.includes('--no-open') && optionalEnv('OAUTH_OPEN_BROWSER') !== 'false'; + if (shouldOpenBrowser) { + openBrowser(authorizationUrl.toString()); + } + + let code: string; + try { + code = await callbackServer.authorizationCode; + } finally { + await callbackServer.close(); + } + + const tokenResponse = await exchangeCode(tokenEndpoint, code, codeVerifier); + const accessTokenIsJwt = tokenResponse.access_token.split('.').length === 3; + const expectJwtAccessToken = optionalEnv('OAUTH_EXPECT_JWT_ACCESS_TOKEN') !== 'false'; + if (expectJwtAccessToken && !accessTokenIsJwt) { + throw new Error( + 'Expected a JWT access token. Enable oauth_jwt_access_tokens for the local Clerk instance or set OAUTH_EXPECT_JWT_ACCESS_TOKEN=false.', + ); + } + + console.log('OAuth access token obtained.'); + console.log(`Access token format: ${accessTokenIsJwt ? 'JWT' : 'opaque'}`); + console.log( + `Granted scope: ${typeof tokenResponse.scope === 'string' ? tokenResponse.scope : '(token response omitted scope)'}`, + ); + + if (process.argv.includes('--print-token')) { + console.log(`Access token: ${tokenResponse.access_token}`); + } + + const resourceResponse = await fetch(resourceServerUrl, { + headers: { Authorization: `Bearer ${tokenResponse.access_token}` }, + }); + const resourceBody = await resourceResponse.text(); + console.log(`\nResource server response: ${resourceResponse.status} ${resourceResponse.statusText}`); + printResponseBody(resourceBody); + + if (!resourceResponse.ok) { + process.exitCode = 1; + } +}; + +main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/examples/oauth-scope-hono/src/server.ts b/examples/oauth-scope-hono/src/server.ts new file mode 100644 index 00000000000..6e8d2babb8a --- /dev/null +++ b/examples/oauth-scope-hono/src/server.ts @@ -0,0 +1,76 @@ +/* eslint-disable turbo/no-undeclared-env-vars */ +import 'dotenv/config'; + +import { clerkMiddleware, getAuth } from '@clerk/hono'; +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { showRoutes } from 'hono/dev'; + +const parsePort = (value: string | undefined): number => { + const port = Number(value ?? '8787'); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid RESOURCE_SERVER_PORT: ${value}`); + } + return port; +}; + +const requiredScope = process.env.OAUTH_REQUIRED_SCOPE?.trim() || 'profile'; +const hostname = process.env.RESOURCE_SERVER_HOST?.trim() || '127.0.0.1'; +const port = parsePort(process.env.RESOURCE_SERVER_PORT); + +const app = new Hono(); +// Middleware +app.use(clerkMiddleware()); + +// Public route +app.get('/', c => + c.json({ + protectedUrl: `/protected`, + requiredScope, + }), +); + +// Protected route +app.get('/protected', c => { + const auth = getAuth(c, { acceptsToken: 'oauth_token' }); + + // Authentication check + if (!auth.isAuthenticated) { + return c.json({ error: 'Not authenticated' }, 401); + } + + // Authorization check — 🆕 `oauth_scope` check. + if (!auth.has({ oauth_scope: requiredScope })) { + return c.json( + { + error: 'Not authorized', + meta: { requiredScope, grantedScopes: auth.scopes }, + }, + 403, + ); + } + + // Authenticated and authorized at this point. + + return c.json({ + authorized: true, + requiredScope, + grantedScopes: auth.scopes, + tokenType: auth.tokenType, + subject: auth.subject, + userId: auth.userId, + }); +}); + +showRoutes(app, { verbose: true }); + +const server = serve({ fetch: app.fetch, hostname, port }, info => { + console.log(`OAuth scope resource server listening on http://${info.address}:${info.port}`); + console.log(`Required scope: ${requiredScope}`); +}); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + server.close(() => process.exit(0)); + }); +} diff --git a/examples/oauth-scope-hono/tsconfig.json b/examples/oauth-scope-hono/tsconfig.json new file mode 100644 index 00000000000..92032def54d --- /dev/null +++ b/examples/oauth-scope-hono/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}