@learncard/lca-api-service 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +23 -13
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +172 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import Fastify, { type FastifyInstance } from 'fastify';
|
|
3
|
+
import { createLocalJWKSet, exportJWK, generateKeyPair, jwtVerify } from 'jose';
|
|
4
|
+
import { oidcFastifyPlugin } from '../src/oidc';
|
|
5
|
+
import { issueLoginTicket, redeemLoginTicket } from '../src/cache/login-tickets';
|
|
6
|
+
import cache from '@cache';
|
|
7
|
+
import { GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
|
8
|
+
import { getOidcSigningKey, resetOidcSigningKeyForTests } from '../src/helpers/oidc.helpers';
|
|
9
|
+
|
|
10
|
+
const { env, entries, sendSecret } = vi.hoisted(() => ({
|
|
11
|
+
sendSecret: vi.fn(),
|
|
12
|
+
env: {
|
|
13
|
+
NODE_ENV: 'test',
|
|
14
|
+
LAMBDA_STAGE: '',
|
|
15
|
+
IS_OFFLINE: false,
|
|
16
|
+
IS_E2E_TEST: false,
|
|
17
|
+
OIDC_ISSUER: 'https://issuer.test',
|
|
18
|
+
OIDC_SIGNING_KEY_JWK: '',
|
|
19
|
+
OIDC_SIGNING_KEY_SECRET_ID: '',
|
|
20
|
+
OIDC_CLIENT_ID: 'keycloak-broker',
|
|
21
|
+
OIDC_CLIENT_SECRET: 'secret',
|
|
22
|
+
OIDC_REDIRECT_URIS: '',
|
|
23
|
+
KEYCLOAK_ISSUERS: 'https://kc.test/realms/test',
|
|
24
|
+
},
|
|
25
|
+
entries: new Map<string, { value: string; expires: number }>(),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('@config/oidcEnvironment', () => ({ environment: env }));
|
|
28
|
+
vi.mock('@aws-sdk/client-secrets-manager', async importOriginal => {
|
|
29
|
+
const actual = await importOriginal<typeof import('@aws-sdk/client-secrets-manager')>();
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
SecretsManagerClient: class {
|
|
33
|
+
send = sendSecret;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
vi.mock('@cache', () => {
|
|
38
|
+
const get = async (key: string): Promise<string | null> => {
|
|
39
|
+
const entry = entries.get(key);
|
|
40
|
+
return entry && entry.expires > Date.now() ? entry.value : null;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
default: {
|
|
44
|
+
get,
|
|
45
|
+
set: async (key: string, value: string, ttl: number): Promise<'OK'> => {
|
|
46
|
+
entries.set(key, { value, expires: Date.now() + ttl * 1000 });
|
|
47
|
+
return 'OK';
|
|
48
|
+
},
|
|
49
|
+
node: {
|
|
50
|
+
eval: async (script: string, _count: number, key: string, ttl: number) => {
|
|
51
|
+
const value = await get(key);
|
|
52
|
+
const attempts = script.includes("redis.call('INCR'")
|
|
53
|
+
? String(Number(value ?? 0) + 1)
|
|
54
|
+
: value;
|
|
55
|
+
if (attempts !== null) {
|
|
56
|
+
entries.set(key, {
|
|
57
|
+
value: attempts,
|
|
58
|
+
expires: entries.get(key)?.expires ?? Date.now() + ttl * 1000,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return attempts;
|
|
62
|
+
},
|
|
63
|
+
getdel: async (key: string): Promise<string | null> => {
|
|
64
|
+
const value = await get(key);
|
|
65
|
+
entries.delete(key);
|
|
66
|
+
return value;
|
|
67
|
+
},
|
|
68
|
+
incr: async (key: string): Promise<number> => {
|
|
69
|
+
const count = (Number(await get(key)) || 0) + 1;
|
|
70
|
+
entries.set(key, {
|
|
71
|
+
value: String(count),
|
|
72
|
+
expires: entries.get(key)?.expires ?? Number.MAX_SAFE_INTEGER,
|
|
73
|
+
});
|
|
74
|
+
return count;
|
|
75
|
+
},
|
|
76
|
+
expire: async (key: string, ttl: number): Promise<number> => {
|
|
77
|
+
const entry = entries.get(key);
|
|
78
|
+
if (!entry) return 0;
|
|
79
|
+
entry.expires = Date.now() + ttl * 1000;
|
|
80
|
+
return 1;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const redirectUri = 'https://kc.test/realms/test/broker/lca-api/endpoint';
|
|
88
|
+
let privateJwk: string;
|
|
89
|
+
// A fresh instance per test: @fastify/rate-limit keeps its in-memory counters
|
|
90
|
+
// on the instance for the whole time window, which would otherwise leak across tests.
|
|
91
|
+
let app: FastifyInstance;
|
|
92
|
+
const authorize = (
|
|
93
|
+
overrides: Record<string, string> = {},
|
|
94
|
+
headers: Record<string, string> = {},
|
|
95
|
+
remoteAddress?: string
|
|
96
|
+
) =>
|
|
97
|
+
app.inject({
|
|
98
|
+
method: 'GET',
|
|
99
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
100
|
+
url: `/oidc/authorize?${new URLSearchParams({
|
|
101
|
+
client_id: env.OIDC_CLIENT_ID,
|
|
102
|
+
redirect_uri: redirectUri,
|
|
103
|
+
response_type: 'code',
|
|
104
|
+
scope: 'openid email profile phone',
|
|
105
|
+
state: 'a & b',
|
|
106
|
+
nonce: 'nonce',
|
|
107
|
+
...overrides,
|
|
108
|
+
})}`,
|
|
109
|
+
headers,
|
|
110
|
+
});
|
|
111
|
+
const ticket = () =>
|
|
112
|
+
issueLoginTicket({
|
|
113
|
+
subject: 'stable-subject',
|
|
114
|
+
identityKey: 'email:test@example.com',
|
|
115
|
+
email: 'test@example.com',
|
|
116
|
+
emailVerified: true,
|
|
117
|
+
name: 'Test',
|
|
118
|
+
picture: 'https://image.test/p',
|
|
119
|
+
phoneNumber: '+15555550100',
|
|
120
|
+
phoneNumberVerified: true,
|
|
121
|
+
});
|
|
122
|
+
const code = async (): Promise<string> => {
|
|
123
|
+
const response = await authorize({ login_hint: await ticket() });
|
|
124
|
+
return new URL(response.headers.location!).searchParams.get('code')!;
|
|
125
|
+
};
|
|
126
|
+
type BasicMode = false | 'encoded' | 'raw';
|
|
127
|
+
const basicHeader = (mode: Exclude<BasicMode, false>): string => {
|
|
128
|
+
const pair =
|
|
129
|
+
mode === 'raw'
|
|
130
|
+
? `${env.OIDC_CLIENT_ID}:${env.OIDC_CLIENT_SECRET}`
|
|
131
|
+
: `${encodeURIComponent(env.OIDC_CLIENT_ID)}:${encodeURIComponent(env.OIDC_CLIENT_SECRET)}`;
|
|
132
|
+
return `Basic ${Buffer.from(pair).toString('base64')}`;
|
|
133
|
+
};
|
|
134
|
+
const exchange = (
|
|
135
|
+
value: string,
|
|
136
|
+
overrides: Record<string, string> = {},
|
|
137
|
+
basic: BasicMode = false,
|
|
138
|
+
headers: Record<string, string> = {},
|
|
139
|
+
remoteAddress?: string
|
|
140
|
+
) =>
|
|
141
|
+
app.inject({
|
|
142
|
+
method: 'POST',
|
|
143
|
+
...(remoteAddress ? { remoteAddress } : {}),
|
|
144
|
+
url: '/oidc/token',
|
|
145
|
+
headers: {
|
|
146
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
147
|
+
...(basic ? { authorization: basicHeader(basic) } : {}),
|
|
148
|
+
...headers,
|
|
149
|
+
},
|
|
150
|
+
payload: new URLSearchParams({
|
|
151
|
+
grant_type: 'authorization_code',
|
|
152
|
+
code: value,
|
|
153
|
+
redirect_uri: redirectUri,
|
|
154
|
+
...(!basic
|
|
155
|
+
? { client_id: env.OIDC_CLIENT_ID, client_secret: env.OIDC_CLIENT_SECRET }
|
|
156
|
+
: {}),
|
|
157
|
+
...overrides,
|
|
158
|
+
}).toString(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
beforeAll(async () => {
|
|
162
|
+
const keys = await generateKeyPair('RS256', { extractable: true });
|
|
163
|
+
privateJwk = JSON.stringify({
|
|
164
|
+
...(await exportJWK(keys.privateKey)),
|
|
165
|
+
kid: 'test-key',
|
|
166
|
+
alg: 'RS256',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
resetOidcSigningKeyForTests();
|
|
171
|
+
sendSecret.mockReset();
|
|
172
|
+
entries.clear();
|
|
173
|
+
app = Fastify();
|
|
174
|
+
await app.register(oidcFastifyPlugin);
|
|
175
|
+
await app.ready();
|
|
176
|
+
Object.assign(env, {
|
|
177
|
+
NODE_ENV: 'test',
|
|
178
|
+
LAMBDA_STAGE: '',
|
|
179
|
+
IS_OFFLINE: false,
|
|
180
|
+
IS_E2E_TEST: false,
|
|
181
|
+
OIDC_ISSUER: 'https://issuer.test',
|
|
182
|
+
OIDC_SIGNING_KEY_JWK: privateJwk,
|
|
183
|
+
OIDC_SIGNING_KEY_SECRET_ID: '',
|
|
184
|
+
OIDC_CLIENT_ID: 'keycloak-broker',
|
|
185
|
+
OIDC_CLIENT_SECRET: 'secret',
|
|
186
|
+
OIDC_REDIRECT_URIS: '',
|
|
187
|
+
KEYCLOAK_ISSUERS: 'https://kc.test/realms/test',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
afterEach(async () => {
|
|
191
|
+
await app.close();
|
|
192
|
+
vi.restoreAllMocks();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('OIDC provider', () => {
|
|
196
|
+
describe('Secrets Manager signing key', () => {
|
|
197
|
+
beforeEach(() => {
|
|
198
|
+
env.LAMBDA_STAGE = 'dev';
|
|
199
|
+
env.OIDC_SIGNING_KEY_JWK = '';
|
|
200
|
+
env.OIDC_SIGNING_KEY_SECRET_ID = 'lca-api/dev/oidc-signing-jwk';
|
|
201
|
+
sendSecret.mockResolvedValue({ SecretString: privateJwk });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('fetches once across concurrent and subsequent callers and resets for tests', async () => {
|
|
205
|
+
const keys = await Promise.all([getOidcSigningKey(), getOidcSigningKey()]);
|
|
206
|
+
expect(keys[0]).toBe(keys[1]);
|
|
207
|
+
expect(await getOidcSigningKey()).toBe(keys[0]);
|
|
208
|
+
expect(sendSecret).toHaveBeenCalledTimes(1);
|
|
209
|
+
const command = sendSecret.mock.calls[0][0];
|
|
210
|
+
expect(command).toBeInstanceOf(GetSecretValueCommand);
|
|
211
|
+
expect(command.input).toEqual({ SecretId: env.OIDC_SIGNING_KEY_SECRET_ID });
|
|
212
|
+
resetOidcSigningKeyForTests();
|
|
213
|
+
await getOidcSigningKey();
|
|
214
|
+
expect(sendSecret).toHaveBeenCalledTimes(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('serves public JWKS and signs verifiable tokens with the fetched key', async () => {
|
|
218
|
+
const response = await exchange(await code());
|
|
219
|
+
expect(response.statusCode).toBe(200);
|
|
220
|
+
const jwks = (await app.inject('/oidc/jwks')).json();
|
|
221
|
+
expect(jwks.keys[0].d).toBeUndefined();
|
|
222
|
+
const { protectedHeader } = await jwtVerify(
|
|
223
|
+
response.json().id_token,
|
|
224
|
+
createLocalJWKSet(jwks)
|
|
225
|
+
);
|
|
226
|
+
expect(protectedHeader.kid).toBe('test-key');
|
|
227
|
+
expect(sendSecret).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('sanitizes fetch failures and retries the next request', async () => {
|
|
231
|
+
sendSecret.mockRejectedValueOnce(new Error(privateJwk));
|
|
232
|
+
await expect(getOidcSigningKey()).rejects.toMatchObject({
|
|
233
|
+
message: 'OIDC signing key could not be fetched',
|
|
234
|
+
cause: undefined,
|
|
235
|
+
});
|
|
236
|
+
expect((await getOidcSigningKey()).kid).toBe('test-key');
|
|
237
|
+
expect(sendSecret).toHaveBeenCalledTimes(2);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it.each([
|
|
241
|
+
'/.well-known/openid-configuration',
|
|
242
|
+
'/oidc/jwks',
|
|
243
|
+
'/oidc/authorize',
|
|
244
|
+
'/oidc/token',
|
|
245
|
+
])('returns 503 on fetch failure for %s and recovers', async url => {
|
|
246
|
+
sendSecret.mockRejectedValueOnce(new Error('unavailable'));
|
|
247
|
+
const response = url === '/oidc/token' ? await exchange('x') : await app.inject(url);
|
|
248
|
+
expect(response.statusCode).toBe(503);
|
|
249
|
+
expect(response.json()).toEqual({ error: 'server_error' });
|
|
250
|
+
expect((await app.inject('/oidc/jwks')).statusCode).toBe(200);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it.each([
|
|
254
|
+
undefined,
|
|
255
|
+
'',
|
|
256
|
+
'not-json',
|
|
257
|
+
'null',
|
|
258
|
+
'{}',
|
|
259
|
+
JSON.stringify({ kty: 'EC', d: 'private', kid: 'key', alg: 'RS256' }),
|
|
260
|
+
JSON.stringify({ kty: 'RSA', kid: 'key', alg: 'RS256' }),
|
|
261
|
+
JSON.stringify({ kty: 'RSA', d: 'private', alg: 'RS256' }),
|
|
262
|
+
JSON.stringify({ kty: 'RSA', d: 'private', kid: 'key', alg: 'HS256' }),
|
|
263
|
+
JSON.stringify({ kty: 'RSA', d: 'private', kid: 'key', alg: 'RS256' }),
|
|
264
|
+
])('rejects invalid or missing SecretString (%#) and retries', async SecretString => {
|
|
265
|
+
sendSecret.mockResolvedValueOnce({ SecretString });
|
|
266
|
+
await expect(getOidcSigningKey()).rejects.toThrow('OIDC signing key');
|
|
267
|
+
expect((await getOidcSigningKey()).kid).toBe('test-key');
|
|
268
|
+
expect(sendSecret).toHaveBeenCalledTimes(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it.each([{}, { SecretString: '' }, { SecretBinary: new Uint8Array([1]) }])(
|
|
272
|
+
'names the missing SecretString instead of an invalid JWK (%#)',
|
|
273
|
+
async result => {
|
|
274
|
+
sendSecret.mockResolvedValueOnce(result);
|
|
275
|
+
await expect(getOidcSigningKey()).rejects.toThrow(
|
|
276
|
+
'OIDC signing key secret has no SecretString value'
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it('prefers the env JWK and reloads when its material changes', async () => {
|
|
282
|
+
env.OIDC_SIGNING_KEY_JWK = privateJwk;
|
|
283
|
+
expect((await getOidcSigningKey()).kid).toBe('test-key');
|
|
284
|
+
env.OIDC_SIGNING_KEY_JWK = JSON.stringify({
|
|
285
|
+
...JSON.parse(privateJwk),
|
|
286
|
+
kid: 'replacement',
|
|
287
|
+
});
|
|
288
|
+
expect((await getOidcSigningKey()).kid).toBe('replacement');
|
|
289
|
+
expect(sendSecret).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('does not fall back to Secrets Manager for an invalid env JWK', async () => {
|
|
293
|
+
env.OIDC_SIGNING_KEY_JWK = 'invalid';
|
|
294
|
+
await expect(getOidcSigningKey()).rejects.toThrow('OIDC signing key');
|
|
295
|
+
expect(sendSecret).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
it('returns a server error without a code redirect when authorization-code storage fails', async () => {
|
|
299
|
+
const loginHint = await ticket();
|
|
300
|
+
vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
|
|
301
|
+
const response = await authorize({ login_hint: loginHint });
|
|
302
|
+
expect(response.statusCode).toBe(500);
|
|
303
|
+
expect(response.json()).toEqual({ error: 'server_error' });
|
|
304
|
+
expect(response.headers.location).toBeUndefined();
|
|
305
|
+
});
|
|
306
|
+
it('returns a server error without tokens when access-token storage fails', async () => {
|
|
307
|
+
const authorizationCode = await code();
|
|
308
|
+
vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
|
|
309
|
+
const response = await exchange(authorizationCode);
|
|
310
|
+
expect(response.statusCode).toBe(500);
|
|
311
|
+
expect(response.json()).toEqual({ error: 'server_error' });
|
|
312
|
+
});
|
|
313
|
+
it('serves discovery with the exact issuer and userinfo endpoint', async () => {
|
|
314
|
+
const response = await app.inject('/.well-known/openid-configuration');
|
|
315
|
+
expect(response.statusCode).toBe(200);
|
|
316
|
+
expect(response.json()).toMatchObject({
|
|
317
|
+
issuer: env.OIDC_ISSUER,
|
|
318
|
+
userinfo_endpoint: `${env.OIDC_ISSUER}/oidc/userinfo`,
|
|
319
|
+
response_types_supported: ['code'],
|
|
320
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
321
|
+
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
|
322
|
+
});
|
|
323
|
+
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
it('publishes only a public RSA JWK with kid and cache control', async () => {
|
|
326
|
+
const response = await app.inject('/oidc/jwks');
|
|
327
|
+
expect(response.headers['cache-control']).toBe('public, max-age=300');
|
|
328
|
+
expect(response.json().keys[0]).toMatchObject({
|
|
329
|
+
kid: 'test-key',
|
|
330
|
+
alg: 'RS256',
|
|
331
|
+
kty: 'RSA',
|
|
332
|
+
});
|
|
333
|
+
for (const field of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'oth'])
|
|
334
|
+
expect(response.json().keys[0][field]).toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
it.each<Record<string, string>>([
|
|
337
|
+
{ client_id: 'wrong' },
|
|
338
|
+
{ redirect_uri: 'https://evil.test/' },
|
|
339
|
+
{ redirect_uri: `${redirectUri}?x=1` },
|
|
340
|
+
{ redirect_uri: `${redirectUri}#fragment` },
|
|
341
|
+
{ redirect_uri: `${redirectUri}/suffix` },
|
|
342
|
+
])('never redirects an unregistered client/URI %j', async overrides => {
|
|
343
|
+
const response = await authorize(overrides);
|
|
344
|
+
expect(response.statusCode).toBe(400);
|
|
345
|
+
expect(response.json()).toEqual({ error: 'invalid_request' });
|
|
346
|
+
expect(response.headers.location).toBeUndefined();
|
|
347
|
+
});
|
|
348
|
+
it('rejects every redirect when both allowlists are empty', async () => {
|
|
349
|
+
env.KEYCLOAK_ISSUERS = '';
|
|
350
|
+
expect((await authorize()).statusCode).toBe(400);
|
|
351
|
+
});
|
|
352
|
+
it('uses an explicit exact allowlist instead of the issuer fallback', async () => {
|
|
353
|
+
env.OIDC_REDIRECT_URIS = 'https://allowed.test/callback';
|
|
354
|
+
expect((await authorize()).statusCode).toBe(400);
|
|
355
|
+
expect((await authorize({ redirect_uri: env.OIDC_REDIRECT_URIS })).statusCode).toBe(302);
|
|
356
|
+
});
|
|
357
|
+
it('tolerates trailing slashes on KEYCLOAK_ISSUERS when deriving the broker callback', async () => {
|
|
358
|
+
env.KEYCLOAK_ISSUERS = 'https://kc.test/realms/test//';
|
|
359
|
+
expect((await authorize()).statusCode).toBe(302);
|
|
360
|
+
expect((await authorize({ redirect_uri: `${redirectUri}/` })).statusCode).toBe(400);
|
|
361
|
+
});
|
|
362
|
+
it.each(['', 'invalid-ticket'])(
|
|
363
|
+
'returns login_required for missing/invalid ticket %s',
|
|
364
|
+
async login_hint => {
|
|
365
|
+
const response = await authorize({ login_hint });
|
|
366
|
+
const url = new URL(response.headers.location!);
|
|
367
|
+
expect(response.statusCode).toBe(302);
|
|
368
|
+
expect(url.searchParams.get('error')).toBe('login_required');
|
|
369
|
+
expect(url.searchParams.get('state')).toBe('a & b');
|
|
370
|
+
expect(response.headers['cache-control']).toBe('no-store');
|
|
371
|
+
}
|
|
372
|
+
);
|
|
373
|
+
it('expires tickets after 60 seconds', async () => {
|
|
374
|
+
const value = await ticket();
|
|
375
|
+
entries.get(`login-ticket:${value}`)!.expires = Date.now() - 1;
|
|
376
|
+
expect(
|
|
377
|
+
new URL((await authorize({ login_hint: value })).headers.location!).searchParams.get(
|
|
378
|
+
'error'
|
|
379
|
+
)
|
|
380
|
+
).toBe('login_required');
|
|
381
|
+
});
|
|
382
|
+
it('returns a code and preserves state once only', async () => {
|
|
383
|
+
const value = await ticket();
|
|
384
|
+
const response = await authorize({ login_hint: value });
|
|
385
|
+
const url = new URL(response.headers.location!);
|
|
386
|
+
expect(url.searchParams.get('code')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
387
|
+
expect(url.searchParams.get('state')).toBe('a & b');
|
|
388
|
+
expect(await redeemLoginTicket(value)).toBeNull();
|
|
389
|
+
});
|
|
390
|
+
it.each([
|
|
391
|
+
[{ response_type: 'token' }, 'unsupported_response_type'],
|
|
392
|
+
[{ scope: 'email' }, 'invalid_scope'],
|
|
393
|
+
] as const)('rejects invalid OAuth parameters %j', async (overrides, error) => {
|
|
394
|
+
const response = await authorize(overrides);
|
|
395
|
+
expect(new URL(response.headers.location!).searchParams.get('error')).toBe(error);
|
|
396
|
+
});
|
|
397
|
+
it.each<BasicMode>(['encoded', 'raw', false])(
|
|
398
|
+
'exchanges a code using Basic=%s and verifies RS256 through served JWKS',
|
|
399
|
+
async basic => {
|
|
400
|
+
const response = await exchange(await code(), {}, basic);
|
|
401
|
+
expect(response.statusCode).toBe(200);
|
|
402
|
+
const tokens = response.json();
|
|
403
|
+
const jwks = (await app.inject('/oidc/jwks')).json();
|
|
404
|
+
const { payload, protectedHeader } = await jwtVerify(
|
|
405
|
+
tokens.id_token,
|
|
406
|
+
createLocalJWKSet(jwks),
|
|
407
|
+
{
|
|
408
|
+
issuer: env.OIDC_ISSUER,
|
|
409
|
+
audience: env.OIDC_CLIENT_ID,
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
expect(protectedHeader).toMatchObject({ alg: 'RS256', typ: 'JWT', kid: 'test-key' });
|
|
413
|
+
expect(payload).toMatchObject({
|
|
414
|
+
sub: 'stable-subject',
|
|
415
|
+
nonce: 'nonce',
|
|
416
|
+
email: 'test@example.com',
|
|
417
|
+
email_verified: true,
|
|
418
|
+
});
|
|
419
|
+
expect(payload.proof).toBeUndefined();
|
|
420
|
+
expect(payload.exp! - payload.iat!).toBe(300);
|
|
421
|
+
expect(tokens).toMatchObject({
|
|
422
|
+
token_type: 'Bearer',
|
|
423
|
+
expires_in: 300,
|
|
424
|
+
scope: 'openid email profile phone',
|
|
425
|
+
});
|
|
426
|
+
expect(tokens.access_token).not.toBe(tokens.id_token);
|
|
427
|
+
expect(response.headers.pragma).toBe('no-cache');
|
|
428
|
+
}
|
|
429
|
+
);
|
|
430
|
+
it('URL-decodes Basic client credentials', async () => {
|
|
431
|
+
env.OIDC_CLIENT_SECRET = 'a+b:c %';
|
|
432
|
+
expect((await exchange(await code(), {}, 'encoded')).statusCode).toBe(200);
|
|
433
|
+
});
|
|
434
|
+
it('accepts raw (Keycloak-style) Basic credentials containing + and %', async () => {
|
|
435
|
+
env.OIDC_CLIENT_SECRET = 'a+b%zz:c%20d/=';
|
|
436
|
+
expect((await exchange(await code(), {}, 'raw')).statusCode).toBe(200);
|
|
437
|
+
});
|
|
438
|
+
it('matches an ambiguous Basic secret against both its raw and decoded forms', async () => {
|
|
439
|
+
const header = `Basic ${Buffer.from(`${env.OIDC_CLIENT_ID}:a%20b`).toString('base64')}`;
|
|
440
|
+
const exchangeWithHeader = async () =>
|
|
441
|
+
app.inject({
|
|
442
|
+
method: 'POST',
|
|
443
|
+
url: '/oidc/token',
|
|
444
|
+
headers: {
|
|
445
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
446
|
+
authorization: header,
|
|
447
|
+
},
|
|
448
|
+
payload: new URLSearchParams({
|
|
449
|
+
grant_type: 'authorization_code',
|
|
450
|
+
code: await code(),
|
|
451
|
+
redirect_uri: redirectUri,
|
|
452
|
+
}).toString(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
env.OIDC_CLIENT_SECRET = 'a%20b';
|
|
456
|
+
expect((await exchangeWithHeader()).statusCode).toBe(200);
|
|
457
|
+
env.OIDC_CLIENT_SECRET = 'a b';
|
|
458
|
+
expect((await exchangeWithHeader()).statusCode).toBe(200);
|
|
459
|
+
env.OIDC_CLIENT_SECRET = 'other';
|
|
460
|
+
expect((await exchangeWithHeader()).statusCode).toBe(401);
|
|
461
|
+
});
|
|
462
|
+
it('rejects a wrong client secret', async () => {
|
|
463
|
+
const response = await exchange(await code(), { client_secret: 'wrong' });
|
|
464
|
+
expect(response.statusCode).toBe(401);
|
|
465
|
+
expect(response.headers['www-authenticate']).toBe('Basic realm="oidc"');
|
|
466
|
+
expect(response.json()).toEqual({ error: 'invalid_client' });
|
|
467
|
+
});
|
|
468
|
+
it('rejects unsupported grants', async () => {
|
|
469
|
+
expect((await exchange('x', { grant_type: 'password' })).json()).toEqual({
|
|
470
|
+
error: 'unsupported_grant_type',
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
it('rejects missing code', async () => {
|
|
474
|
+
expect((await exchange('')).json()).toEqual({ error: 'invalid_request' });
|
|
475
|
+
});
|
|
476
|
+
it('rejects unknown codes', async () => {
|
|
477
|
+
expect((await exchange('unknown')).json()).toEqual({ error: 'invalid_grant' });
|
|
478
|
+
});
|
|
479
|
+
it('rejects code replay', async () => {
|
|
480
|
+
const value = await code();
|
|
481
|
+
expect((await exchange(value)).statusCode).toBe(200);
|
|
482
|
+
expect((await exchange(value)).json()).toEqual({ error: 'invalid_grant' });
|
|
483
|
+
});
|
|
484
|
+
it('rejects mismatched redirect URIs', async () => {
|
|
485
|
+
expect(
|
|
486
|
+
(await exchange(await code(), { redirect_uri: 'https://wrong.test/' })).json()
|
|
487
|
+
).toEqual({ error: 'invalid_grant' });
|
|
488
|
+
});
|
|
489
|
+
it('serves userinfo from opaque access tokens without nonce or identityKey', async () => {
|
|
490
|
+
const tokens = (await exchange(await code())).json();
|
|
491
|
+
const response = await app.inject({
|
|
492
|
+
url: '/oidc/userinfo',
|
|
493
|
+
headers: { authorization: `Bearer ${tokens.access_token}` },
|
|
494
|
+
});
|
|
495
|
+
expect(response.statusCode).toBe(200);
|
|
496
|
+
expect(response.json()).toEqual({
|
|
497
|
+
sub: 'stable-subject',
|
|
498
|
+
email: 'test@example.com',
|
|
499
|
+
email_verified: true,
|
|
500
|
+
name: 'Test',
|
|
501
|
+
picture: 'https://image.test/p',
|
|
502
|
+
phone_number: '+15555550100',
|
|
503
|
+
phone_number_verified: true,
|
|
504
|
+
});
|
|
505
|
+
expect(entries.has(`oidc:access:${tokens.access_token}`)).toBe(true);
|
|
506
|
+
});
|
|
507
|
+
it.each(['', 'Bearer invalid'])(
|
|
508
|
+
'rejects invalid userinfo credentials %s',
|
|
509
|
+
async authorization => {
|
|
510
|
+
const response = await app.inject({
|
|
511
|
+
url: '/oidc/userinfo',
|
|
512
|
+
headers: { authorization },
|
|
513
|
+
});
|
|
514
|
+
expect(response.statusCode).toBe(401);
|
|
515
|
+
expect(response.headers['www-authenticate']).toBe('Bearer error="invalid_token"');
|
|
516
|
+
}
|
|
517
|
+
);
|
|
518
|
+
it.each([
|
|
519
|
+
'/.well-known/openid-configuration',
|
|
520
|
+
'/oidc/jwks',
|
|
521
|
+
'/oidc/authorize',
|
|
522
|
+
'/oidc/userinfo',
|
|
523
|
+
])('fails closed without issuer on %s', async url => {
|
|
524
|
+
env.OIDC_ISSUER = '';
|
|
525
|
+
const response = await app.inject(url);
|
|
526
|
+
expect(response.statusCode).toBe(503);
|
|
527
|
+
expect(response.json()).toEqual({ error: 'server_error' });
|
|
528
|
+
});
|
|
529
|
+
it.each([
|
|
530
|
+
{ NODE_ENV: 'production', LAMBDA_STAGE: '' },
|
|
531
|
+
{ NODE_ENV: 'development', LAMBDA_STAGE: 'dev' },
|
|
532
|
+
{ NODE_ENV: 'development', LAMBDA_STAGE: 'prod' },
|
|
533
|
+
])('fails closed without a signing key when deployed (%j)', async overrides => {
|
|
534
|
+
Object.assign(env, overrides, { OIDC_SIGNING_KEY_JWK: '' });
|
|
535
|
+
expect((await app.inject('/oidc/jwks')).statusCode).toBe(503);
|
|
536
|
+
expect((await exchange('x')).statusCode).toBe(503);
|
|
537
|
+
});
|
|
538
|
+
it('still generates an ephemeral key under serverless-offline with LAMBDA_STAGE set', async () => {
|
|
539
|
+
Object.assign(env, { LAMBDA_STAGE: 'dev', IS_OFFLINE: true, OIDC_SIGNING_KEY_JWK: '' });
|
|
540
|
+
expect((await app.inject('/oidc/jwks')).statusCode).toBe(200);
|
|
541
|
+
});
|
|
542
|
+
it('fails closed without client secret', async () => {
|
|
543
|
+
env.OIDC_CLIENT_SECRET = '';
|
|
544
|
+
expect((await exchange('x')).statusCode).toBe(503);
|
|
545
|
+
});
|
|
546
|
+
it('generates one ephemeral development key when unset', async () => {
|
|
547
|
+
env.OIDC_SIGNING_KEY_JWK = '';
|
|
548
|
+
const first = (await app.inject('/oidc/jwks')).json();
|
|
549
|
+
expect(first.keys[0].kty).toBe('RSA');
|
|
550
|
+
expect((await app.inject('/oidc/jwks')).json()).toEqual(first);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe('per-IP failures-only rate limiting', () => {
|
|
554
|
+
const rateKeys = () => [...entries.keys()].filter(key => key.startsWith('oidc-rl:'));
|
|
555
|
+
const otherIp = '203.0.113.7';
|
|
556
|
+
const expectRateLimited = (response: {
|
|
557
|
+
statusCode: number;
|
|
558
|
+
headers: Record<string, unknown>;
|
|
559
|
+
json: () => unknown;
|
|
560
|
+
}) => {
|
|
561
|
+
expect(response.statusCode).toBe(429);
|
|
562
|
+
expect(response.headers['retry-after']).toBe('600');
|
|
563
|
+
expect(response.json()).toEqual({ error: 'temporarily_unavailable' });
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
it('never counts successful authorize/token round-trips', async () => {
|
|
567
|
+
for (let index = 0; index < 5; index++) {
|
|
568
|
+
expect((await exchange(await code())).statusCode).toBe(200);
|
|
569
|
+
}
|
|
570
|
+
expect(rateKeys()).toEqual([]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('does not count non-credential protocol errors at the token endpoint', async () => {
|
|
574
|
+
await exchange('x', { grant_type: 'password' });
|
|
575
|
+
await exchange('');
|
|
576
|
+
expect(rateKeys()).toEqual([]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('limits the token endpoint after 50 invalid_client failures', async () => {
|
|
580
|
+
for (let index = 0; index < 50; index++) {
|
|
581
|
+
expect((await exchange(await code(), { client_secret: 'wrong' })).statusCode).toBe(
|
|
582
|
+
401
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
expect(entries.get('oidc-rl:token:127.0.0.1')?.value).toBe('50');
|
|
586
|
+
|
|
587
|
+
expectRateLimited(await exchange(await code()));
|
|
588
|
+
expectRateLimited(await exchange('x', { client_secret: 'wrong' }));
|
|
589
|
+
|
|
590
|
+
expect((await exchange(await code(), {}, false, {}, otherIp)).statusCode).toBe(200);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('never counts invalid_grant from an authenticated client against its IP', async () => {
|
|
594
|
+
// The broker relays whatever code a browser hands it from one shared
|
|
595
|
+
// egress IP; counting those would let anyone lock every user out.
|
|
596
|
+
for (let index = 0; index < 60; index++) {
|
|
597
|
+
expect((await exchange('unknown-code')).statusCode).toBe(400);
|
|
598
|
+
}
|
|
599
|
+
expect(rateKeys()).toEqual([]);
|
|
600
|
+
expect((await exchange(await code())).statusCode).toBe(200);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('limits authorize after 50 invalid tickets and redirects with temporarily_unavailable', async () => {
|
|
604
|
+
for (let index = 0; index < 50; index++) {
|
|
605
|
+
const response = await authorize({ login_hint: 'bogus' });
|
|
606
|
+
expect(new URL(response.headers.location!).searchParams.get('error')).toBe(
|
|
607
|
+
'login_required'
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const value = await ticket();
|
|
612
|
+
const limitedResponse = await authorize({ login_hint: value });
|
|
613
|
+
expect(limitedResponse.statusCode).toBe(302);
|
|
614
|
+
const url = new URL(limitedResponse.headers.location!);
|
|
615
|
+
expect(url.origin + url.pathname).toBe(redirectUri);
|
|
616
|
+
expect(url.searchParams.get('error')).toBe('temporarily_unavailable');
|
|
617
|
+
expect(url.searchParams.get('state')).toBe('a & b');
|
|
618
|
+
expect(await redeemLoginTicket(value)).not.toBeNull();
|
|
619
|
+
|
|
620
|
+
expectRateLimited(await authorize({ redirect_uri: 'https://evil.test/' }));
|
|
621
|
+
expect((await authorize({ login_hint: await ticket() }, {}, otherIp)).statusCode).toBe(
|
|
622
|
+
302
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('limits authorize after 50 invalid_request failures with a 429, never a redirect', async () => {
|
|
627
|
+
for (let index = 0; index < 50; index++) {
|
|
628
|
+
expect((await authorize({ client_id: 'wrong' })).statusCode).toBe(400);
|
|
629
|
+
}
|
|
630
|
+
const response = await authorize({ client_id: 'wrong' });
|
|
631
|
+
expectRateLimited(response);
|
|
632
|
+
expect(response.headers.location).toBeUndefined();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('keys the limit on the transport peer address, never x-forwarded-for', async () => {
|
|
636
|
+
const peer = '198.51.100.9';
|
|
637
|
+
const spoofed = { 'x-forwarded-for': '203.0.113.50, 10.0.0.2' };
|
|
638
|
+
for (let index = 0; index < 50; index++) {
|
|
639
|
+
await exchange('nope', { client_secret: 'wrong' }, false, spoofed, peer);
|
|
640
|
+
}
|
|
641
|
+
expect(entries.has(`oidc-rl:token:${peer}`)).toBe(true);
|
|
642
|
+
expect(entries.has('oidc-rl:token:203.0.113.50')).toBe(false);
|
|
643
|
+
|
|
644
|
+
// Rotating the header does not escape the bucket ...
|
|
645
|
+
expectRateLimited(
|
|
646
|
+
await exchange(await code(), {}, false, { 'x-forwarded-for': '203.0.113.51' }, peer)
|
|
647
|
+
);
|
|
648
|
+
// ... and a different peer is unaffected.
|
|
649
|
+
expect((await exchange(await code())).statusCode).toBe(200);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('sets the 10-minute window when the key is first created', async () => {
|
|
653
|
+
const before = Date.now();
|
|
654
|
+
await exchange('nope', { client_secret: 'wrong' });
|
|
655
|
+
const entry = entries.get('oidc-rl:token:127.0.0.1')!;
|
|
656
|
+
expect(entry.expires).toBeGreaterThanOrEqual(before + 600_000);
|
|
657
|
+
expect(entry.expires).toBeLessThan(before + 601_000);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
describe('@fastify/rate-limit request ceiling', () => {
|
|
662
|
+
it('gives the server-to-server /oidc/token a high sanity ceiling, not the browser cap', async () => {
|
|
663
|
+
// Every legitimate token call comes from the broker's single egress
|
|
664
|
+
// IP, so a 60/min cap here would be a global login ceiling.
|
|
665
|
+
for (let index = 0; index < 61; index++) {
|
|
666
|
+
const response = await exchange('x', { grant_type: 'password' });
|
|
667
|
+
expect(response.statusCode).toBe(400);
|
|
668
|
+
expect(response.headers['x-ratelimit-limit']).toBe('3000');
|
|
669
|
+
}
|
|
670
|
+
expect([...entries.keys()].filter(key => key.startsWith('oidc-rl:'))).toEqual([]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('gives /oidc/userinfo the same server-to-server ceiling', async () => {
|
|
674
|
+
const response = await app.inject('/oidc/userinfo');
|
|
675
|
+
expect(response.statusCode).toBe(401);
|
|
676
|
+
expect(response.headers['x-ratelimit-limit']).toBe('3000');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('caps /oidc/authorize at 60 requests per minute per IP', async () => {
|
|
680
|
+
for (let index = 0; index < 60; index++) {
|
|
681
|
+
expect((await authorize({ response_type: 'token' })).statusCode).toBe(302);
|
|
682
|
+
}
|
|
683
|
+
const limited = await authorize({ login_hint: await ticket() });
|
|
684
|
+
expect(limited.statusCode).toBe(429);
|
|
685
|
+
expect(limited.headers.location).toBeUndefined();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('leaves discovery and JWKS on the generous global ceiling', async () => {
|
|
689
|
+
for (let index = 0; index < 61; index++) {
|
|
690
|
+
expect((await app.inject('/oidc/jwks')).statusCode).toBe(200);
|
|
691
|
+
}
|
|
692
|
+
const response = await app.inject('/.well-known/openid-configuration');
|
|
693
|
+
expect(response.statusCode).toBe(200);
|
|
694
|
+
expect(response.headers['x-ratelimit-limit']).toBe('300');
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
});
|