Skip to content

Commit fbf1fc8

Browse files
committed
feat: has({ oauth_scope: 'scope' })
1 parent 1ef84c3 commit fbf1fc8

10 files changed

Lines changed: 320 additions & 10 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/backend': minor
3+
'@clerk/nextjs': minor
4+
'@clerk/shared': minor
5+
---
6+
7+
OAuth access-token auth objects now support `has({ oauth_scope: 'scope' })` to authorize against the exact scopes granted in the token.

‎integration/tests/hono/machine.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,17 @@ app.get('/m2m', c => {
103103
.addFile('src/server/app.ts', () =>
104104
createAppFile(`
105105
app.get('/oauth-verify', c => {
106-
const { userId, tokenType } = getAuth(c, { acceptsToken: 'oauth_token' });
106+
const auth = getAuth(c, { acceptsToken: 'oauth_token' });
107107
108-
if (!userId) {
108+
if (!auth.userId) {
109109
return c.text('Unauthorized', 401);
110110
}
111111
112-
return c.json({ userId, tokenType });
112+
if (!auth.has({ oauth_scope: 'profile' })) {
113+
return c.text('Forbidden', 403);
114+
}
115+
116+
return c.json({ userId: auth.userId, tokenType: auth.tokenType });
113117
});
114118
115119
app.get('/oauth/callback', c => {

‎packages/backend/src/tokens/__tests__/authObjects.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,14 @@ describe('authenticatedMachineObject', () => {
358358
expect(retrievedToken).toBe(token);
359359
});
360360

361-
it('has() always returns false', () => {
361+
it('has() checks exact OAuth scopes and denies other authorization checks', () => {
362362
const authObject = authenticatedMachineObject('oauth_token', token, verificationResult, debugData);
363+
expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(true);
364+
expect(authObject.has({ oauth_scope: 'write:bar' })).toBe(true);
365+
expect(authObject.has({ oauth_scope: 'read:baz' })).toBe(false);
366+
expect(authObject.has({ oauth_scope: 'READ:FOO' })).toBe(false);
367+
expect(authObject.has({ oauth_scope: '' })).toBe(false);
368+
expect(authObject.has({ role: 'org:admin' })).toBe(false);
363369
expect(authObject.has({})).toBe(false);
364370
});
365371

@@ -415,6 +421,18 @@ describe('unauthenticatedMachineObject', () => {
415421
expect(authObject.has({})).toBe(false);
416422
});
417423

424+
it('accepts OAuth scope checks and returns false for unauthenticated OAuth tokens', () => {
425+
const authObject = unauthenticatedMachineObject('oauth_token');
426+
expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(false);
427+
});
428+
429+
it('does not expose OAuth scope checks on other machine token types', () => {
430+
const authObject = unauthenticatedMachineObject('api_key');
431+
432+
// @ts-expect-error OAuth scope checks are only available for OAuth tokens.
433+
expect(authObject.has({ oauth_scope: 'read:foo' })).toBe(false);
434+
});
435+
418436
it('getToken always returns null ', async () => {
419437
const authObject = unauthenticatedMachineObject('m2m_token');
420438
const retrievedToken = await authObject.getToken();

‎packages/backend/src/tokens/__tests__/getAuth.test-d.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,14 @@ describe('getAuth() or auth() with request parameter', () => {
4242
expectTypeOf(auth).toExtend<SessionAuthObject>();
4343
} else if (auth.tokenType === 'api_key') {
4444
expectTypeOf(auth).toExtend<MachineAuthObject<'api_key'>>();
45+
// @ts-expect-error OAuth scope checks are only available for OAuth tokens.
46+
auth.has({ oauth_scope: 'profile' });
4547
} else if (auth.tokenType === 'm2m_token') {
4648
expectTypeOf(auth).toExtend<MachineAuthObject<'m2m_token'>>();
4749
} else if (auth.tokenType === 'oauth_token') {
4850
expectTypeOf(auth).toExtend<MachineAuthObject<'oauth_token'>>();
51+
auth.has({ oauth_scope: 'profile' });
52+
auth.has({ role: 'org:admin' });
4953
}
5054
});
5155
});

‎packages/backend/src/tokens/authObjects.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { createCheckAuthorization } from '@clerk/shared/authorization';
1+
import { createCheckAuthorization, createCheckAuthorizationFromOAuthScopes } from '@clerk/shared/authorization';
22
import { __experimental_JWTPayloadToAuthObjectProperties } from '@clerk/shared/jwtPayloadParser';
33
import type {
4+
CheckAuthorizationFromOAuthScopes,
45
CheckAuthorizationFromSessionClaims,
56
Jwt,
67
JwtPayload,
@@ -31,6 +32,10 @@ type AuthObjectDebug = () => AuthObjectDebugData;
3132

3233
type Claims = Record<string, any>;
3334

35+
type CheckAuthorizationFromMachineToken<T extends MachineTokenType> = T extends typeof TokenType.OAuthToken
36+
? CheckAuthorizationFromOAuthScopes
37+
: CheckAuthorizationFromSessionClaims;
38+
3439
/**
3540
* @internal
3641
*/
@@ -119,7 +124,7 @@ export type AuthenticatedMachineObject<T extends MachineTokenType = MachineToken
119124
subject: string;
120125
scopes: string[];
121126
getToken: () => Promise<string>;
122-
has: CheckAuthorizationFromSessionClaims;
127+
has: CheckAuthorizationFromMachineToken<T>;
123128
debug: AuthObjectDebug;
124129
tokenType: T;
125130
isAuthenticated: true;
@@ -140,7 +145,7 @@ export type UnauthenticatedMachineObject<T extends MachineTokenType = MachineTok
140145
subject: null;
141146
scopes: null;
142147
getToken: () => Promise<null>;
143-
has: CheckAuthorizationFromSessionClaims;
148+
has: CheckAuthorizationFromMachineToken<T>;
144149
debug: AuthObjectDebug;
145150
tokenType: T;
146151
isAuthenticated: false;
@@ -310,6 +315,10 @@ export function authenticatedMachineObject<T extends MachineTokenType>(
310315
scopes: result.scopes,
311316
userId: result.subject,
312317
clientId: result.clientId,
318+
has: createCheckAuthorizationFromOAuthScopes({
319+
userId: result.subject,
320+
oauthScopes: result.scopes,
321+
}),
313322
} as unknown as AuthenticatedMachineObject<T>;
314323
}
315324
default:

‎packages/nextjs/src/server/__tests__/getAuthDataFromRequest.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,86 @@ describe('getAuthDataFromRequest', () => {
166166
expect(auth.isAuthenticated).toBe(true);
167167
});
168168

169+
it('reconstructs OAuth scope authorization from encrypted machine auth data', () => {
170+
const machineAuthObject = createMockMachineAuthObject({
171+
tokenType: 'oauth_token',
172+
id: 'oat_id123',
173+
subject: 'user_12345',
174+
userId: 'user_12345',
175+
clientId: 'client_12345',
176+
scopes: ['profile', 'email'],
177+
isAuthenticated: true,
178+
});
179+
180+
const req = mockRequest({
181+
url: '/api/protected',
182+
headers: new Headers({
183+
[constants.Headers.Authorization]: 'Bearer oat_secret123',
184+
}),
185+
machineAuthObject,
186+
});
187+
188+
const auth = getAuthDataFromRequest(req, { acceptsToken: 'oauth_token' });
189+
190+
expect(auth.tokenType).toBe('oauth_token');
191+
expect(auth.isAuthenticated).toBe(true);
192+
if (auth.tokenType !== 'oauth_token') {
193+
throw new Error('Expected an OAuth auth object');
194+
}
195+
expect(auth.has({ oauth_scope: 'profile' })).toBe(true);
196+
expect(auth.has({ oauth_scope: 'email' })).toBe(true);
197+
expect(auth.has({ oauth_scope: 'openid' })).toBe(false);
198+
expect(auth.has({ oauth_scope: 'PROFILE' })).toBe(false);
199+
});
200+
201+
it('keeps OAuth scope authorization disabled for unauthenticated reconstructed auth data', () => {
202+
const machineAuthObject = createMockMachineAuthObject({
203+
tokenType: 'oauth_token',
204+
isAuthenticated: false,
205+
});
206+
207+
const req = mockRequest({
208+
url: '/api/protected',
209+
headers: new Headers({
210+
[constants.Headers.Authorization]: 'Bearer oat_invalid',
211+
}),
212+
machineAuthObject,
213+
});
214+
215+
const auth = getAuthDataFromRequest(req, { acceptsToken: 'oauth_token' });
216+
217+
expect(auth.tokenType).toBe('oauth_token');
218+
expect(auth.isAuthenticated).toBe(false);
219+
if (auth.tokenType !== 'oauth_token') {
220+
throw new Error('Expected an OAuth auth object');
221+
}
222+
expect(auth.has({ oauth_scope: 'profile' })).toBe(false);
223+
});
224+
225+
it('keeps non-OAuth machine token authorization disabled after reconstruction', () => {
226+
const machineAuthObject = createMockMachineAuthObject({
227+
tokenType: 'api_key',
228+
id: 'ak_id123',
229+
subject: 'user_12345',
230+
scopes: ['profile'],
231+
isAuthenticated: true,
232+
});
233+
234+
const req = mockRequest({
235+
url: '/api/protected',
236+
headers: new Headers({
237+
[constants.Headers.Authorization]: 'Bearer ak_secret123',
238+
}),
239+
machineAuthObject,
240+
});
241+
242+
const auth = getAuthDataFromRequest(req, { acceptsToken: 'api_key' });
243+
244+
expect(auth.tokenType).toBe('api_key');
245+
expect(auth.isAuthenticated).toBe(true);
246+
expect(auth.has({})).toBe(false);
247+
});
248+
169249
it('returns authenticated object when token type exists in acceptsToken array', () => {
170250
const machineAuthObject = createMockMachineAuthObject({
171251
tokenType: 'api_key',

‎packages/nextjs/src/server/data/getAuthDataFromRequest.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TokenType,
1818
} from '@clerk/backend/internal';
1919
import { decodeJwt } from '@clerk/backend/jwt';
20+
import { createCheckAuthorizationFromOAuthScopes } from '@clerk/shared/authorization';
2021
import type { PendingSessionOptions } from '@clerk/shared/types';
2122

2223
import type { LoggerNoCommit } from '../../utils/debugLogger';
@@ -171,7 +172,13 @@ const handleMachineToken = (
171172
return {
172173
...authObject,
173174
getToken: () => (authObject.isAuthenticated ? Promise.resolve(bearerToken) : Promise.resolve(null)),
174-
has: () => false,
175+
has:
176+
authObject.tokenType === TokenType.OAuthToken
177+
? createCheckAuthorizationFromOAuthScopes({
178+
userId: authObject.userId,
179+
oauthScopes: authObject.scopes,
180+
})
181+
: () => false,
175182
} as MachineAuthObject<MachineTokenType>;
176183
}
177184

‎packages/shared/src/__tests__/authorization.spec.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { createCheckAuthorization, splitByScope } from '../authorization';
3+
import { createCheckAuthorization, createCheckAuthorizationFromOAuthScopes, splitByScope } from '../authorization';
44

55
describe('createCheckAuthorization', () => {
66
it('correctly parses features', () => {
@@ -377,6 +377,91 @@ describe('createCheckAuthorization', () => {
377377
});
378378
});
379379

380+
describe('createCheckAuthorizationFromOAuthScopes', () => {
381+
it('authorizes an exact, case-sensitive scope match', () => {
382+
const has = createCheckAuthorizationFromOAuthScopes({
383+
userId: 'user_123',
384+
oauthScopes: ['profile', 'Email'],
385+
});
386+
387+
expect(has({ oauth_scope: 'profile' })).toBe(true);
388+
expect(has({ oauth_scope: 'Email' })).toBe(true);
389+
expect(has({ oauth_scope: 'PROFILE' })).toBe(false);
390+
expect(has({ oauth_scope: 'email' })).toBe(false);
391+
expect(has({ oauth_scope: 'missing' })).toBe(false);
392+
});
393+
394+
it('fails closed for invalid requested scopes', () => {
395+
const has = createCheckAuthorizationFromOAuthScopes({
396+
userId: 'user_123',
397+
oauthScopes: ['profile'],
398+
});
399+
400+
expect(has({ oauth_scope: '' })).toBe(false);
401+
expect(has({ oauth_scope: null } as any)).toBe(false);
402+
expect(has({ oauth_scope: 123 } as any)).toBe(false);
403+
expect(has({ oauth_scope: undefined } as any)).toBe(false);
404+
});
405+
406+
it.each([undefined, null, {}, ['profile', 123], ['profile', '']])(
407+
'fails closed for malformed granted scopes: %j',
408+
oauthScopes => {
409+
const has = createCheckAuthorizationFromOAuthScopes({
410+
userId: 'user_123',
411+
oauthScopes: oauthScopes as any,
412+
});
413+
414+
expect(has({ oauth_scope: 'profile' })).toBe(false);
415+
},
416+
);
417+
418+
it.each([undefined, null, ''])('fails when userId is missing: %j', userId => {
419+
const has = createCheckAuthorizationFromOAuthScopes({
420+
userId,
421+
oauthScopes: ['profile'],
422+
});
423+
424+
expect(has({ oauth_scope: 'profile' })).toBe(false);
425+
});
426+
427+
it('accepts previous session authorization shapes but denies them', () => {
428+
const has = createCheckAuthorizationFromOAuthScopes({
429+
userId: 'user_123',
430+
oauthScopes: ['profile'],
431+
});
432+
433+
expect(has({ role: 'org:admin' })).toBe(false);
434+
expect(has({ permission: 'org:read' })).toBe(false);
435+
expect(has({ feature: 'user:premium' })).toBe(false);
436+
expect(has({ plan: 'user:pro' })).toBe(false);
437+
expect(has({ reverification: 'strict' })).toBe(false);
438+
});
439+
440+
it.each([
441+
{ role: 'org:admin' },
442+
{ permission: 'org:read' },
443+
{ feature: 'user:premium' },
444+
{ plan: 'user:pro' },
445+
{ reverification: 'strict' },
446+
])('denies a valid OAuth scope mixed with another recognized dimension: %j', params => {
447+
const has = createCheckAuthorizationFromOAuthScopes({
448+
userId: 'user_123',
449+
oauthScopes: ['profile'],
450+
});
451+
452+
expect(has({ oauth_scope: 'profile', ...params } as any)).toBe(false);
453+
});
454+
455+
it('fails when no authorization dimension is requested', () => {
456+
const has = createCheckAuthorizationFromOAuthScopes({
457+
userId: 'user_123',
458+
oauthScopes: ['profile'],
459+
});
460+
461+
expect(has({})).toBe(false);
462+
});
463+
});
464+
380465
describe('splitByScope', () => {
381466
it('correctly splits features by scope', () => {
382467
const { org, user } = splitByScope('o:reservations,u:dashboard');

0 commit comments

Comments
 (0)