Skip to content

Commit fd5b5a4

Browse files
committed
opaque claim
1 parent 2be4cbf commit fd5b5a4

3 files changed

Lines changed: 68 additions & 49 deletions

File tree

packages/shared/src/types/jwtv2.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,9 @@ type JWTPayloadBase = {
9898
sts?: SessionStatusClaim;
9999

100100
/**
101-
* Reserved session claim. When present, the session is operating under
102-
* a restricted scope and SDK consumers should treat affected
103-
* functionality as unavailable. The claim shape is opaque and may
104-
* change without notice.
101+
* Reserved session claim. The claim shape is opaque and may change
102+
* without notice. Do not depend on its presence or absence for
103+
* authorization checks.
105104
*/
106105
ams?: AmsClaim;
107106

@@ -223,20 +222,9 @@ export type AgentActClaim = ActClaim & { type: 'agent' };
223222
export type SessionStatusClaim = Extract<SessionStatus, 'active' | 'pending'>;
224223

225224
/**
226-
* Shape of the optional `ams` session claim. Carries an identifier and a
227-
* list of opaque scope strings that constrain what the session is
228-
* permitted to do. Both fields should be treated as opaque tokens; their
229-
* vocabulary is owned by the issuer and may change without notice.
225+
* Opaque optional `ams` session claim. Consumers must not inspect its value
226+
* or depend on its presence or absence for authorization checks.
230227
*
231228
* @inline
232229
*/
233-
export interface AmsClaim {
234-
/**
235-
* Opaque identifier the session is scoped to.
236-
*/
237-
app_id: string;
238-
/**
239-
* Opaque scope strings. Consumers should test for membership only.
240-
*/
241-
scopes: string[];
242-
}
230+
export type AmsClaim = unknown;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { JwtPayload } from '@clerk/shared/types';
2+
import { renderHook } from '@testing-library/react';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { useAms } from '../useAms';
6+
7+
const { mockUseSession } = vi.hoisted(() => ({
8+
mockUseSession: vi.fn(),
9+
}));
10+
11+
vi.mock('@clerk/shared/react', () => ({
12+
useSession: mockUseSession,
13+
}));
14+
15+
const mockClaims = (claims: JwtPayload | undefined) => {
16+
mockUseSession.mockReturnValue({
17+
session: claims
18+
? {
19+
lastActiveToken: {
20+
jwt: { claims },
21+
},
22+
}
23+
: null,
24+
});
25+
};
26+
27+
describe('useAms', () => {
28+
beforeEach(() => {
29+
mockUseSession.mockReset();
30+
});
31+
32+
it('returns inactive when the ams claim is absent', () => {
33+
mockClaims({ __raw: 'token' } as JwtPayload);
34+
35+
const { result } = renderHook(() => useAms());
36+
37+
expect(result.current).toEqual({ isActive: false });
38+
});
39+
40+
it('returns active when the ams claim is present without reading its value', () => {
41+
const claims = { __raw: 'token' } as JwtPayload;
42+
Object.defineProperty(claims, 'ams', {
43+
enumerable: true,
44+
get: () => {
45+
throw new Error('ams should be opaque');
46+
},
47+
});
48+
mockClaims(claims);
49+
50+
const { result } = renderHook(() => useAms());
51+
52+
expect(result.current).toEqual({ isActive: true });
53+
});
54+
});

packages/ui/src/hooks/useAms.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,32 @@
11
import { useSession } from '@clerk/shared/react';
2-
import type { AmsClaim } from '@clerk/shared/types';
32

43
/**
5-
* Return shape for {@link useAms}. Splits the "claim absent" case from
6-
* the "claim present" case so callers can use `hasScope` unconditionally
7-
* — when the claim is absent `hasScope` always returns `true`, meaning
8-
* sites that read `if (!hasScope('foo')) hide()` keep working on
9-
* regular sessions.
4+
* Return shape for {@link useAms}. The claim is opaque, so callers should
5+
* only branch on whether it is present.
106
*/
11-
export type UseAmsReturn =
12-
| {
13-
isActive: false;
14-
appId: undefined;
15-
scopes: undefined;
16-
hasScope: () => true;
17-
}
18-
| {
19-
isActive: true;
20-
appId: string;
21-
scopes: string[];
22-
hasScope: (scope: string) => boolean;
23-
};
7+
export type UseAmsReturn = {
8+
isActive: boolean;
9+
};
2410

2511
const INACTIVE: UseAmsReturn = {
2612
isActive: false,
27-
appId: undefined,
28-
scopes: undefined,
29-
hasScope: () => true,
3013
};
3114

3215
/**
3316
* Returns information about the optional `ams` claim on the active
34-
* session. When the claim is present the session is operating under a
35-
* restricted scope; the returned `hasScope` helper can be used to gate
36-
* UI on individual scope strings.
17+
* session. The claim shape is intentionally opaque.
3718
*
3819
* Reactive — re-renders when the session token rotates.
3920
*/
4021
export const useAms = (): UseAmsReturn => {
4122
const { session } = useSession();
42-
const ams = session?.lastActiveToken?.jwt?.claims.ams as AmsClaim | undefined;
23+
const claims = session?.lastActiveToken?.jwt?.claims;
4324

44-
if (!ams || typeof ams.app_id !== 'string') {
25+
if (!claims || !Object.prototype.hasOwnProperty.call(claims, 'ams')) {
4526
return INACTIVE;
4627
}
4728

48-
const scopes = Array.isArray(ams.scopes) ? ams.scopes : [];
4929
return {
5030
isActive: true,
51-
appId: ams.app_id,
52-
scopes,
53-
hasScope: (scope: string) => scopes.includes(scope),
5431
};
5532
};

0 commit comments

Comments
 (0)