Skip to content

Commit 05bd563

Browse files
committed
feat(stack): propagate structured auth refusals
1 parent 0c48036 commit 05bd563

39 files changed

Lines changed: 2920 additions & 185 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@cipherstash/nextjs': minor
3+
---
4+
5+
Report what the CipherStash token service actually said when it refuses a token.
6+
7+
`getCtsToken()` reported a non-2xx response as `Failed to fetch CTS token: ` and
8+
nothing else. It read `statusText`, which is the empty string over HTTP/2 — so
9+
the message ended at the colon — and it discarded the response body, taking any
10+
refusal code with it. A billing refusal was indistinguishable from a bad token,
11+
and the accompanying log said "contact support", which is the wrong advice for
12+
an organisation that needs to upgrade a plan.
13+
14+
The failure now names the status and quotes what the service returned, and the
15+
refusal code is surfaced on a new optional `authCode` field of
16+
`GetCtsTokenResponse` — `USAGE_LIMIT_EXCEEDED` for an organisation over its
17+
allowance, `ORG_NOT_PROVISIONED` for one not registered with the usage system.
18+
Both are terminal: retrying cannot clear either. Unknown `402` codes are
19+
declined so a future payment-required response does not inherit the wrong
20+
classification.
21+
22+
The body is read as text exactly once and then parsed defensively, never with
23+
`response.json()`. The two shapes are not the same shape: a `402` is JSON, while
24+
every other failure from this endpoint is `text/plain` (a `401` is the bare
25+
string `Authorization failed: InvalidToken`), and `.json()` on one of those
26+
throws a `SyntaxError` that replaces the real failure with a parse error. A
27+
response that is not a recognisable CipherStash refusal — a gateway or WAF
28+
answering in front of the service — still reports its status and body rather
29+
than being reported as a billing problem it is not.
30+
31+
This package does not depend on `@cipherstash/stack`, so it carries the code
32+
rather than a copy of that package's remedy text — look the remedy up from
33+
`authCode` if you need to render one.
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/**
2+
* `getCtsToken()` (and the `fetchCtsToken` under it) against a CTS that says no.
3+
*
4+
* This package talks to `POST /api/authorize` directly — the same endpoint
5+
* `LockContext.identify()` in `@cipherstash/stack` calls, and the same one that
6+
* answers a billing refusal with a `402`. `fetch` RESOLVES for a `402`: nothing
7+
* throws, so the failure has to be read off the status and the body.
8+
*
9+
* Credential-free: `fetch` is stubbed, so there is no CTS round-trip.
10+
*
11+
* The bodies below are the ones CTS actually sends, and the two shapes are not
12+
* the same shape:
13+
*
14+
* - A **402** is JSON — `AuthorizeErrorBody` in `cts-web/src/authorize/mod.rs`,
15+
* i.e. `{"error":"usage_limit_exceeded","error_description":"...",
16+
* "cs_code":"USAGE_LIMIT_EXCEEDED"}`.
17+
* - **Everything else** is plain text. A live probe answers `401` with the bare
18+
* string `Authorization failed: InvalidToken`, and over HTTP/2 with an EMPTY
19+
* `statusText` — which is the whole reason this file exists.
20+
*/
21+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22+
23+
// No CipherStash session cookie in the request: that is the branch of
24+
// `getCtsToken` which exchanges the supplied OIDC token with CTS.
25+
vi.mock('next/headers', () => ({
26+
cookies: vi.fn(async () => ({ get: () => undefined })),
27+
}))
28+
29+
vi.mock('../../utils/logger', () => ({
30+
logger: {
31+
debug: vi.fn(),
32+
error: vi.fn(),
33+
info: vi.fn(),
34+
warn: vi.fn(),
35+
},
36+
}))
37+
38+
import { logger } from '../../utils/logger'
39+
import { getCtsToken } from '../src/index'
40+
41+
/**
42+
* A CTS response as `fetch` would resolve it. `Response` leaves `statusText`
43+
* empty unless one is passed, which is exactly what the live endpoint does over
44+
* HTTP/2 — so the pre-fix message really did carry nothing.
45+
*/
46+
const ctsResponds = (
47+
status: number,
48+
body: string,
49+
contentType = 'text/plain; charset=utf-8',
50+
) =>
51+
vi.fn(
52+
async () =>
53+
new Response(body, {
54+
status,
55+
headers: { 'content-type': contentType },
56+
}),
57+
)
58+
59+
/** A CTS success: the `{ accessToken, expiry }` shape `/api/authorize` mints. */
60+
const ctsIssuesToken = () =>
61+
vi.fn(
62+
async () =>
63+
new Response(
64+
JSON.stringify({ accessToken: 'cts-token', expiry: 1_900_000_000 }),
65+
{ status: 200, headers: { 'content-type': 'application/json' } },
66+
),
67+
)
68+
69+
/** Every string this failure was logged with, in call order. */
70+
const loggedMessages = () =>
71+
[...vi.mocked(logger.debug).mock.calls, ...vi.mocked(logger.error).mock.calls]
72+
.flat()
73+
.filter((arg): arg is string => typeof arg === 'string')
74+
75+
beforeEach(() => {
76+
process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace'
77+
vi.clearAllMocks()
78+
})
79+
80+
afterEach(() => {
81+
vi.unstubAllGlobals()
82+
})
83+
84+
/** The 402 body CTS sends, as `AuthorizeErrorBody` serialises it. */
85+
const refusalBody = (
86+
error: string,
87+
csCode: string | undefined,
88+
description: string,
89+
) =>
90+
JSON.stringify({
91+
error,
92+
error_description: description,
93+
// `cs_code` is `skip_serializing_if = "Option::is_none"` upstream, so an
94+
// absent one is an absent KEY, not a null.
95+
...(csCode ? { cs_code: csCode } : {}),
96+
})
97+
98+
describe('getCtsToken(): a CTS refusal reaches the caller as one', () => {
99+
it('surfaces a usage-limit 402 with its refusal code and what CTS said', async () => {
100+
// Pre-fix this returned the bare string "Failed to fetch CTS token: " —
101+
// `statusText` is empty over HTTP/2, so the caller got a failure with no
102+
// status, no body, and no code to branch on.
103+
vi.stubGlobal(
104+
'fetch',
105+
ctsResponds(
106+
402,
107+
refusalBody(
108+
'usage_limit_exceeded',
109+
'USAGE_LIMIT_EXCEEDED',
110+
'Workspace has exceeded its usage limit and cannot issue an access token',
111+
),
112+
'application/json',
113+
),
114+
)
115+
116+
const result = await getCtsToken('a-user-jwt')
117+
118+
expect(result.success).toBe(false)
119+
expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED')
120+
expect(result.error).toContain('402')
121+
// The service's own sentence, not the JSON envelope around it.
122+
expect(result.error).toContain('Workspace has exceeded its usage limit')
123+
expect(result.error).not.toContain('error_description')
124+
})
125+
126+
it('distinguishes an unprovisioned org from an over-limit one', async () => {
127+
vi.stubGlobal(
128+
'fetch',
129+
ctsResponds(
130+
402,
131+
refusalBody(
132+
'org_not_provisioned',
133+
'ORG_NOT_PROVISIONED',
134+
'Organisation is not provisioned in the usage system and cannot issue an access token',
135+
),
136+
'application/json',
137+
),
138+
)
139+
140+
const result = await getCtsToken('a-user-jwt')
141+
142+
expect(result.authCode).toBe('ORG_NOT_PROVISIONED')
143+
expect(result.error).toContain('Organisation is not provisioned')
144+
})
145+
146+
it('declines an unknown refusal code', async () => {
147+
vi.stubGlobal(
148+
'fetch',
149+
ctsResponds(
150+
402,
151+
refusalBody('access_denied', 'SOME_FUTURE_REFUSAL', 'Nope.'),
152+
'application/json',
153+
),
154+
)
155+
156+
const result = await getCtsToken('a-user-jwt')
157+
158+
expect(result.authCode).toBeUndefined()
159+
expect(result.error).toContain('Nope.')
160+
})
161+
162+
it('reads a pre-cs_code 402 off `error` when the key is absent', async () => {
163+
vi.stubGlobal(
164+
'fetch',
165+
ctsResponds(
166+
402,
167+
refusalBody('usage_limit_exceeded', undefined, 'Over the limit.'),
168+
'application/json',
169+
),
170+
)
171+
172+
const result = await getCtsToken('a-user-jwt')
173+
174+
expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED')
175+
})
176+
177+
it('classifies a legacy OAuth 402 without cs_code as the usage limit', async () => {
178+
vi.stubGlobal(
179+
'fetch',
180+
ctsResponds(
181+
402,
182+
refusalBody('access_denied', undefined, 'Over the limit.'),
183+
'application/json',
184+
),
185+
)
186+
187+
const result = await getCtsToken('a-user-jwt')
188+
189+
expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED')
190+
})
191+
192+
it('reads a bodyless 402 as the usage limit', async () => {
193+
vi.stubGlobal('fetch', ctsResponds(402, ''))
194+
195+
const result = await getCtsToken('a-user-jwt')
196+
197+
expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED')
198+
})
199+
200+
it('degrades to a plain error when the 402 did not come from CTS', async () => {
201+
// A gateway, WAF or proxy in front of CTS answers with prose or a page of
202+
// HTML, not an `AuthorizeErrorBody`. That must produce an honest error,
203+
// never an invented code — reporting it as a billing refusal sends the
204+
// caller to a billing page for something a retry would have cleared.
205+
vi.stubGlobal('fetch', ctsResponds(402, 'Payment Required'))
206+
207+
const result = await getCtsToken('a-user-jwt')
208+
209+
expect(result.success).toBe(false)
210+
expect(result.authCode).toBeUndefined()
211+
expect(result.error).toContain('402')
212+
expect(result.error).toContain('Payment Required')
213+
})
214+
215+
it('logs the reason instead of an empty statusText', async () => {
216+
// The defect was in the logs as much as in the return value: both lines
217+
// reported `statusText`, or nothing at all.
218+
vi.stubGlobal(
219+
'fetch',
220+
ctsResponds(
221+
402,
222+
refusalBody(
223+
'usage_limit_exceeded',
224+
'USAGE_LIMIT_EXCEEDED',
225+
'Workspace has exceeded its usage limit and cannot issue an access token',
226+
),
227+
'application/json',
228+
),
229+
)
230+
231+
await getCtsToken('a-user-jwt')
232+
233+
const logged = loggedMessages()
234+
expect(logged.some((message) => message.includes('402'))).toBe(true)
235+
expect(
236+
logged.some((message) =>
237+
message.includes('Workspace has exceeded its usage limit'),
238+
),
239+
).toBe(true)
240+
// Nothing may be logged as a message that trails off into an empty
241+
// `statusText`.
242+
expect(logged.every((message) => !/[:\s]$/.test(message))).toBe(true)
243+
})
244+
})
245+
246+
describe('getCtsToken(): other non-2xx statuses are not mislabelled', () => {
247+
// Verbatim from a live probe of the real endpoint — an expired or malformed
248+
// user JWT is by far the likeliest way to land here, and it must not read as
249+
// a billing problem.
250+
const cases: ReadonlyArray<[number, string]> = [
251+
[401, 'Authorization failed: InvalidToken'],
252+
[403, 'Forbidden'],
253+
[500, 'Internal Server Error'],
254+
]
255+
256+
for (const [status, body] of cases) {
257+
it(`keeps a ${status} as a plain CTS token failure`, async () => {
258+
vi.stubGlobal('fetch', ctsResponds(status, body))
259+
260+
const result = await getCtsToken('a-user-jwt')
261+
262+
expect(result.success).toBe(false)
263+
expect(result.authCode).toBeUndefined()
264+
// The status and what the server actually said — the two things a caller
265+
// needs, and neither of which survived before.
266+
expect(result.error).toContain(String(status))
267+
expect(result.error).toContain(body)
268+
// No billing language on a failure that is not a billing failure.
269+
expect(result.error).not.toMatch(/billing|upgrade|dashboard/i)
270+
})
271+
}
272+
273+
it('survives an empty error body without trailing punctuation debris', async () => {
274+
vi.stubGlobal('fetch', ctsResponds(502, ''))
275+
276+
const result = await getCtsToken('a-user-jwt')
277+
278+
expect(result.success).toBe(false)
279+
expect(result.error).toContain('502')
280+
expect(result.error).not.toMatch(/[:\s]$/)
281+
})
282+
283+
it('caps a runaway body and does not read a code out of it', async () => {
284+
// A gateway in front of CTS answers with a page of HTML, not a sentence.
285+
// The quote is capped so it does not become the whole error, and nothing
286+
// in it is mistaken for a refusal code.
287+
const padding = '<html><body>'.padEnd(600, 'x')
288+
289+
vi.stubGlobal('fetch', ctsResponds(402, padding))
290+
291+
const result = await getCtsToken('a-user-jwt')
292+
293+
expect(result.authCode).toBeUndefined()
294+
expect(result.error).not.toContain(padding)
295+
expect(result.error?.length).toBeLessThan(padding.length)
296+
})
297+
})
298+
299+
describe('getCtsToken(): the 2xx paths are unchanged', () => {
300+
it('still returns the token on the happy path', async () => {
301+
vi.stubGlobal('fetch', ctsIssuesToken())
302+
303+
const result = await getCtsToken('a-user-jwt')
304+
305+
expect(result.success).toBe(true)
306+
expect(result.error).toBeUndefined()
307+
expect(result.ctsToken).toEqual({
308+
accessToken: 'cts-token',
309+
expiry: 1_900_000_000,
310+
})
311+
})
312+
313+
it('still rejects on a 2xx whose body is not JSON', async () => {
314+
// Pinned as-is, NOT fixed here: a 200 with an unparseable body throws out of
315+
// `fetchCtsToken` today, and the refusal fix deliberately leaves the 2xx
316+
// path alone. Recorded so a later change to it is a decision rather than an
317+
// accident.
318+
vi.stubGlobal('fetch', ctsResponds(200, 'not json at all'))
319+
320+
await expect(getCtsToken('a-user-jwt')).rejects.toThrow()
321+
})
322+
})

0 commit comments

Comments
 (0)