@flink-app/oidc-plugin 2.0.0-alpha.116 → 2.0.0-alpha.118
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/CHANGELOG.md +55 -0
- package/README.md +296 -17
- package/dist/OidcPlugin.d.ts.map +1 -1
- package/dist/OidcPlugin.js +35 -2
- package/dist/OidcPluginContext.d.ts +17 -0
- package/dist/OidcPluginContext.d.ts.map +1 -1
- package/dist/OidcPluginOptions.d.ts +123 -3
- package/dist/OidcPluginOptions.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.js +97 -20
- package/dist/handlers/InitiateOidc.d.ts.map +1 -1
- package/dist/handlers/InitiateOidc.js +12 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/providers/ProviderRegistry.d.ts +27 -4
- package/dist/providers/ProviderRegistry.d.ts.map +1 -1
- package/dist/providers/ProviderRegistry.js +77 -21
- package/dist/utils/error-utils.d.ts +1 -0
- package/dist/utils/error-utils.d.ts.map +1 -1
- package/dist/utils/error-utils.js +1 -0
- package/dist/utils/redirect-uri-utils.d.ts +38 -0
- package/dist/utils/redirect-uri-utils.d.ts.map +1 -0
- package/dist/utils/redirect-uri-utils.js +92 -0
- package/examples/basic-oidc.ts +4 -0
- package/examples/multi-provider.ts +4 -0
- package/package.json +6 -6
- package/spec/handlers/CallbackOidc.spec.ts +330 -0
- package/spec/handlers/InitiateOidc.spec.ts +95 -1
- package/spec/helpers/test-helpers.ts +68 -0
- package/spec/plugin/OidcPlugin.spec.ts +165 -0
- package/spec/providers/ProviderRegistry.spec.ts +142 -0
- package/spec/utils/redirect-uri-utils.spec.ts +98 -0
- package/src/OidcPlugin.ts +38 -1
- package/src/OidcPluginContext.ts +18 -0
- package/src/OidcPluginOptions.ts +132 -3
- package/src/handlers/CallbackOidc.ts +109 -20
- package/src/handlers/InitiateOidc.ts +14 -3
- package/src/index.ts +10 -1
- package/src/providers/ProviderRegistry.ts +95 -22
- package/src/utils/error-utils.ts +1 -0
- package/src/utils/redirect-uri-utils.ts +116 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# @flink-app/oidc-plugin
|
|
2
2
|
|
|
3
|
+
## 2.0.0-alpha.118
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 581497b: **Security:** add `allowedRedirectUris` to restrict the `?redirectUri=` query parameter on `/oidc/:provider/initiate`.
|
|
8
|
+
|
|
9
|
+
The `redirectUri` decides where the host's JWT is delivered once the OIDC flow completes, and anyone can craft an initiate link. Until now every value was accepted, so a crafted link could turn a legitimate login into token exfiltration — the user authenticates at the real IdP and the plugin then redirects to the attacker's URL with `#token=...`.
|
|
10
|
+
|
|
11
|
+
`allowedRedirectUris` takes a list of exact URIs, bare origins (`https://app.example.com` covers every path below it, `myapp://cb` covers a native scheme) or a predicate. Relative paths and the provider's own `callbackUrl` are always accepted. A mismatch answers `400` with the new `OidcErrorCodes.INVALID_REDIRECT_URI` and no session is created. The same check is applied to a `redirectUrl` returned by `onAuthSuccess` / `onAuthError` when it differs from the session's, so echoing an attacker-supplied value back cannot bypass the list by accident.
|
|
12
|
+
|
|
13
|
+
Non-breaking: when the option is omitted the behaviour is unchanged (every `redirectUri` accepted) and the plugin logs a warning at startup. Set it on every deployment — a future major will make it required.
|
|
14
|
+
|
|
15
|
+
Also: OIDC error codes are now propagated to the `code` field of `400` responses from the initiate and callback handlers (previously always `badRequest`).
|
|
16
|
+
|
|
17
|
+
- 52d9152: Add `providerCacheTtlSec` and `ctx.plugins.oidc.invalidateProvider()` so provider config can change without a restart.
|
|
18
|
+
|
|
19
|
+
`ProviderRegistry` cached every resolved provider for the life of the process and never re-ran `providerLoader` for a name it had seen, so for hosts that keep provider config in a database, editing an issuer, client id, client secret, scope or discovery URL only took effect after restarting every instance. `clearCache()` existed on the registry but was not reachable through `ctx.plugins.oidc`.
|
|
20
|
+
|
|
21
|
+
- `providerCacheTtlSec` (default `0` = cache forever, unchanged): seconds a resolved provider is reused before its config is loaded and discovery is run again. Reloading is lazy, on the next request for that provider. If the reload fails, the previously cached instance keeps being used and the next attempt happens after another TTL — a discovery hiccup does not break logins that would otherwise work. If the loader returns `null` the cached instance is dropped and the request fails with `provider_not_configured`. Applies to static providers too, whose discovery metadata was otherwise never refreshed.
|
|
22
|
+
- `ctx.plugins.oidc.invalidateProvider(providerName?)`: drops cached instance(s) immediately on the instance it runs on — call it from the handler that saves IdP config; other instances converge within the TTL.
|
|
23
|
+
|
|
24
|
+
Both absent → exactly the previous behaviour. Non-breaking.
|
|
25
|
+
|
|
26
|
+
- 37c5251: Allow `onAuthSuccess` to complete the login without a `token`.
|
|
27
|
+
|
|
28
|
+
Until now `onAuthSuccess` had to return a JWT and the plugin alone decided how it reached the client (`#token=` fragment, `?token=` query, or `{user, token}` JSON). Applications that want an HttpOnly cookie session, a one-time code the client exchanges over POST, or their own login envelope had to rebuild initiate/callback on top of `OidcProvider`.
|
|
29
|
+
|
|
30
|
+
`AuthSuccessCallbackResponse` is now a union of three shapes:
|
|
31
|
+
|
|
32
|
+
- `{ user, token, redirectUrl? }` — unchanged default; the plugin delivers the token per `tokenDelivery`
|
|
33
|
+
- `{ user, redirectUrl }` — the plugin redirects there verbatim, attaching nothing (e.g. the URL carries a short-lived one-time code). Still subject to `allowedRedirectUris`; `?response_type=json` answers `400 invalid_response_type` for this shape since there is no token for the body
|
|
34
|
+
- `{ user, response }` — the application returns the whole `FlinkResponse` (redirect + `Set-Cookie`, a custom JSON envelope, anything a handler may return)
|
|
35
|
+
|
|
36
|
+
`onAuthError` takes the same `response` escape hatch, which wins over `redirectUrl`.
|
|
37
|
+
|
|
38
|
+
The `storeTokens` connection upsert now runs for all three shapes (it only needs `user._id`), and returning none of `token`, `redirectUrl` or `response` still fails with `jwt_generation_failed`.
|
|
39
|
+
|
|
40
|
+
Existing code is unaffected — the token shape is part of the union and behaves exactly as before.
|
|
41
|
+
|
|
42
|
+
Docs: a "Completing the login yourself" section in the README and skill, plus an explicit note that `oidc_connections` rows are only written when `storeTokens: true`, so in auth-only mode `getConnection`/`getConnections` cannot serve as the application's identity link.
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 1767278: Docs: bring the README in line with the current behaviour — `tokenDelivery` gets an options-table row and its own section (it was only documented in passing), the "returns the token as a URL fragment" statements are qualified with "by default", the React Native example uses query delivery (iOS strips the fragment) and points at `allowedRedirectUris` for custom schemes, and the `onAuthSuccess` / `onAuthError` signatures now show `redirectUri`, `metadata`, the three success shapes and the `response` escape hatch.
|
|
47
|
+
- @flink-app/flink@2.0.0-alpha.118
|
|
48
|
+
- @flink-app/jwt-auth-plugin@2.0.0-alpha.118
|
|
49
|
+
|
|
50
|
+
## 2.0.0-alpha.117
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- Updated dependencies [24e0399]
|
|
55
|
+
- @flink-app/flink@2.0.0-alpha.117
|
|
56
|
+
- @flink-app/jwt-auth-plugin@2.0.0-alpha.117
|
|
57
|
+
|
|
3
58
|
## 2.0.0-alpha.116
|
|
4
59
|
|
|
5
60
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -106,6 +106,10 @@ const app = new FlinkApp<Context>({
|
|
|
106
106
|
},
|
|
107
107
|
},
|
|
108
108
|
|
|
109
|
+
// Where the JWT may be delivered after login. ALWAYS set this —
|
|
110
|
+
// without it any ?redirectUri= passed to /initiate is accepted.
|
|
111
|
+
allowedRedirectUris: ["https://myapp.com"],
|
|
112
|
+
|
|
109
113
|
// Callback after successful OIDC authentication (JIT provisioning)
|
|
110
114
|
onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
|
|
111
115
|
// Find user by OIDC subject + issuer (unique IdP identifier)
|
|
@@ -162,14 +166,17 @@ await app.start();
|
|
|
162
166
|
| Option | Type | Required | Default | Description |
|
|
163
167
|
| --------------------------- | ---------- | -------- | ------------------ | ------------------------------------------------------ |
|
|
164
168
|
| `providers` | `object` | Yes | - | OIDC provider configurations (at least one required) |
|
|
169
|
+
| `allowedRedirectUris` | `string[]` \| `Function` | No | (all accepted) | Allow-list for `?redirectUri=` — **set this** (see [Redirect URI Allow-List](#redirect-uri-allow-list)) |
|
|
165
170
|
| `storeTokens` | `boolean` | No | `false` | Store encrypted OIDC tokens for future API access |
|
|
166
171
|
| `onAuthSuccess` | `Function` | Yes | - | Callback after successful authentication (JIT) |
|
|
167
172
|
| `onAuthError` | `Function` | No | - | Callback on OIDC errors |
|
|
168
173
|
| `providerLoader` | `Function` | No | - | Dynamic provider loading from database |
|
|
174
|
+
| `providerCacheTtlSec` | `number` | No | `0` (forever) | Seconds a resolved provider is reused before its config and discovery are refreshed |
|
|
169
175
|
| `sessionTTL` | `number` | No | `600` | Session TTL in seconds (default: 10 minutes) |
|
|
170
176
|
| `sessionsCollectionName` | `string` | No | `"oidc_sessions"` | MongoDB collection for sessions |
|
|
171
177
|
| `connectionsCollectionName` | `string` | No | `"oidc_connections"` | MongoDB collection for connections |
|
|
172
178
|
| `encryptionKey` | `string` | No | (derived) | Encryption key for tokens (32+ chars recommended) |
|
|
179
|
+
| `tokenDelivery` | `"fragment"` \| `"query"` \| `Function` | No | `"fragment"` | How the JWT is attached on the post-callback redirect (see [Token Delivery](#token-delivery-fragment-vs-query)) |
|
|
173
180
|
| `registerRoutes` | `boolean` | No | `true` | Auto-register OIDC routes |
|
|
174
181
|
|
|
175
182
|
### Provider Configuration
|
|
@@ -227,22 +234,30 @@ claims only:
|
|
|
227
234
|
|
|
228
235
|
#### onAuthSuccess
|
|
229
236
|
|
|
230
|
-
Called when OIDC authentication succeeds.
|
|
237
|
+
Called when OIDC authentication succeeds. Resolves the user and decides how the login completes.
|
|
231
238
|
|
|
232
239
|
```typescript
|
|
233
240
|
onAuthSuccess: async (params: {
|
|
234
|
-
profile: OidcProfile;
|
|
235
|
-
claims: Record<string, any>;
|
|
236
|
-
provider: string;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
profile: OidcProfile; // Normalized user profile
|
|
242
|
+
claims: Record<string, any>; // Raw OIDC claims
|
|
243
|
+
provider: string; // Provider name
|
|
244
|
+
redirectUri: string; // redirectUri from the initiate request (or the fallback)
|
|
245
|
+
metadata: Record<string, string>; // meta.* query params from initiate, prefix stripped
|
|
246
|
+
tokens?: OidcTokenSet; // Only if storeTokens: true
|
|
247
|
+
}, ctx: Context) =>
|
|
248
|
+
Promise<
|
|
249
|
+
// 1. Default — the plugin delivers the token per `tokenDelivery`
|
|
250
|
+
| { user: any; token: string; redirectUrl?: string }
|
|
251
|
+
// 2. The plugin redirects there verbatim, attaching nothing
|
|
252
|
+
| { user: any; redirectUrl: string }
|
|
253
|
+
// 3. Your app returns the whole response (Set-Cookie, custom envelope, ...)
|
|
254
|
+
| { user: any; response: FlinkResponse }
|
|
255
|
+
>
|
|
243
256
|
```
|
|
244
257
|
|
|
245
|
-
**Important:**
|
|
258
|
+
**Important:** with shape 1 the `redirectUrl` should NOT include the token — the plugin
|
|
259
|
+
appends it (`#token=...` by default). Shapes 2 and 3 are described in
|
|
260
|
+
[Completing the Login Yourself](#completing-the-login-yourself).
|
|
246
261
|
|
|
247
262
|
#### onAuthError
|
|
248
263
|
|
|
@@ -252,8 +267,11 @@ Called when OIDC authentication fails.
|
|
|
252
267
|
onAuthError: async (params: {
|
|
253
268
|
error: OidcError;
|
|
254
269
|
provider: string;
|
|
270
|
+
redirectUri?: string; // From the initiate request, if the session was found
|
|
271
|
+
metadata: Record<string, string>; // meta.* query params from initiate
|
|
255
272
|
}) => Promise<{
|
|
256
273
|
redirectUrl?: string;
|
|
274
|
+
response?: FlinkResponse; // Takes precedence over redirectUrl
|
|
257
275
|
}>
|
|
258
276
|
```
|
|
259
277
|
|
|
@@ -321,6 +339,27 @@ GET /oidc/acme/initiate?redirectUri=https://myapp.com/welcome
|
|
|
321
339
|
|
|
322
340
|
- 302 redirect to IdP authorization URL
|
|
323
341
|
|
|
342
|
+
#### Security: the `redirectUri` query parameter
|
|
343
|
+
|
|
344
|
+
`redirectUri` decides where the JWT is delivered when the flow completes, and it is
|
|
345
|
+
**attacker-controllable** — anyone can craft an initiate link. Without an allow-list a
|
|
346
|
+
crafted link turns a legitimate login into token exfiltration: the user authenticates at
|
|
347
|
+
the real IdP, and the plugin then redirects to the attacker's URL with `#token=eyJ...`.
|
|
348
|
+
|
|
349
|
+
Set [`allowedRedirectUris`](#redirect-uri-allow-list) on every deployment.
|
|
350
|
+
|
|
351
|
+
Prefer deriving the final redirect from `metadata` (the `meta.*` query params) rather
|
|
352
|
+
than echoing `redirectUri` straight back from `onAuthSuccess`, so the allow-list is a
|
|
353
|
+
second line of defence rather than the only one:
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
onAuthSuccess: async ({ metadata }, ctx) => {
|
|
357
|
+
// ...
|
|
358
|
+
const redirectUrl = metadata.intent === "admin" ? "https://admin.myapp.com/auth" : "/dashboard";
|
|
359
|
+
return { user, token, redirectUrl };
|
|
360
|
+
};
|
|
361
|
+
```
|
|
362
|
+
|
|
324
363
|
### OIDC Callback
|
|
325
364
|
|
|
326
365
|
```
|
|
@@ -354,9 +393,39 @@ https://myapp.com/dashboard#token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
|
354
393
|
}
|
|
355
394
|
```
|
|
356
395
|
|
|
396
|
+
### Token Delivery (fragment vs query)
|
|
397
|
+
|
|
398
|
+
By default the JWT is attached to the post-callback redirect as a **URL fragment**
|
|
399
|
+
(`#token=...`) — fragments are never sent to the server, which keeps the token out of
|
|
400
|
+
access logs and referrers.
|
|
401
|
+
|
|
402
|
+
Native flows need the query form instead: iOS `ASWebAuthenticationSession` strips the
|
|
403
|
+
fragment before handing the URL to the app, so a custom-scheme callback never sees it.
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
// Static: "fragment" (default) | "query"
|
|
407
|
+
oidcPlugin({ tokenDelivery: "query", ... });
|
|
408
|
+
|
|
409
|
+
// Per request, when one backend serves both web and native clients
|
|
410
|
+
oidcPlugin({
|
|
411
|
+
tokenDelivery: ({ redirectUrl, provider, metadata }) => (/^https?:/i.test(redirectUrl) ? "fragment" : "query"),
|
|
412
|
+
...
|
|
413
|
+
});
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
The function form receives the resolved `redirectUrl`, the `provider` name, and the
|
|
417
|
+
`metadata` bag from the initiate request (`?meta.client=native`), so the app can route by
|
|
418
|
+
URL scheme, by explicit intent, or any other signal. `?response_type=json` ignores the
|
|
419
|
+
setting and returns `{user, token}` as JSON.
|
|
420
|
+
|
|
421
|
+
To keep the token out of the URL altogether, see
|
|
422
|
+
[Completing the Login Yourself](#completing-the-login-yourself).
|
|
423
|
+
|
|
357
424
|
### Extracting JWT Token in Frontend
|
|
358
425
|
|
|
359
|
-
**IMPORTANT:**
|
|
426
|
+
**IMPORTANT:** By default the plugin returns the JWT token as a **URL fragment**
|
|
427
|
+
(`#token=...`), NOT as a query parameter (`?token=...`) — read it accordingly unless you
|
|
428
|
+
set [`tokenDelivery: "query"`](#token-delivery-fragment-vs-query).
|
|
360
429
|
|
|
361
430
|
```javascript
|
|
362
431
|
// ✅ CORRECT - Read from URL fragment (hash)
|
|
@@ -375,10 +444,81 @@ const params = new URLSearchParams(window.location.search);
|
|
|
375
444
|
const token = params.get("token"); // This returns null!
|
|
376
445
|
```
|
|
377
446
|
|
|
447
|
+
## Completing the Login Yourself
|
|
448
|
+
|
|
449
|
+
Returning `{ user, token }` is the simple default: the plugin attaches the token per
|
|
450
|
+
[`tokenDelivery`](#token-delivery-fragment-vs-query) (fragment, query or JSON). Two other shapes let the
|
|
451
|
+
application complete the login itself when a token in the URL is not the session model it
|
|
452
|
+
wants — an HttpOnly cookie, a one-time code, or a login envelope its clients already parse.
|
|
453
|
+
|
|
454
|
+
### Verbatim redirect (one-time code)
|
|
455
|
+
|
|
456
|
+
Return a `redirectUrl` and no `token`: the plugin redirects there **exactly as given** and
|
|
457
|
+
attaches nothing. Keeps the bearer token out of browser history, referrers and the access
|
|
458
|
+
logs of the redirect target.
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
onAuthSuccess: async ({ profile, claims }, ctx) => {
|
|
462
|
+
const user = await findOrCreateUser(ctx, claims);
|
|
463
|
+
|
|
464
|
+
// Short-lived, single-use code the client exchanges for a real session
|
|
465
|
+
const code = generateCode();
|
|
466
|
+
await ctx.repos.loginCodeRepo.create({ code, userId: user._id, expiresAt: new Date(Date.now() + 60_000) });
|
|
467
|
+
|
|
468
|
+
return { user, redirectUrl: `https://app.example.com/auth/done?code=${code}` };
|
|
469
|
+
};
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The client then POSTs the code to one of your own routes, which answers with your normal
|
|
473
|
+
login response. The URL is still subject to [`allowedRedirectUris`](#redirect-uri-allow-list).
|
|
474
|
+
|
|
475
|
+
`?response_type=json` is not available for this shape — the request answers
|
|
476
|
+
`400 invalid_response_type`, since there is no token to put in the JSON body.
|
|
477
|
+
|
|
478
|
+
### Host-built response (cookie session, custom envelope)
|
|
479
|
+
|
|
480
|
+
Return a `response` and the plugin sends it as-is — anything a Flink handler may return:
|
|
481
|
+
|
|
482
|
+
```typescript
|
|
483
|
+
// HttpOnly session cookie + redirect
|
|
484
|
+
onAuthSuccess: async ({ profile, claims }, ctx) => {
|
|
485
|
+
const user = await findOrCreateUser(ctx, claims);
|
|
486
|
+
const session = await ctx.repos.sessionRepo.create({ userId: user._id });
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
user,
|
|
490
|
+
response: {
|
|
491
|
+
redirect: { to: "https://app.example.com/dashboard" },
|
|
492
|
+
headers: { "Set-Cookie": `session=${session.id}; HttpOnly; Secure; SameSite=Lax; Path=/` },
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// Custom login envelope for native clients
|
|
498
|
+
onAuthSuccess: async ({ profile, claims }, ctx) => {
|
|
499
|
+
const user = await findOrCreateUser(ctx, claims);
|
|
500
|
+
const { accessToken, refreshToken } = await issueTokens(ctx, user);
|
|
501
|
+
|
|
502
|
+
return { user, response: { data: { accessToken, refreshToken, profile: user } } };
|
|
503
|
+
};
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
`onAuthError` takes the same `response` escape hatch (it wins over `redirectUrl`), e.g. to
|
|
507
|
+
clear a session cookie alongside the error redirect.
|
|
508
|
+
|
|
509
|
+
**In all three shapes** the OIDC connection is still stored when `storeTokens: true` —
|
|
510
|
+
it only needs `user._id`. Returning none of `token`, `redirectUrl` or `response` fails the
|
|
511
|
+
login with `jwt_generation_failed`, as before.
|
|
512
|
+
|
|
378
513
|
## Context API
|
|
379
514
|
|
|
380
515
|
The plugin exposes methods via `ctx.plugins.oidc`:
|
|
381
516
|
|
|
517
|
+
> **Note:** `oidc_connections` rows are only written when `storeTokens: true`. In auth-only
|
|
518
|
+
> mode (the default) `getConnection` / `getConnections` always return nothing, so they
|
|
519
|
+
> cannot serve as the application's identity link — keep that link on your own user
|
|
520
|
+
> document, as in [JIT with Email Matching](#jit-with-email-matching).
|
|
521
|
+
|
|
382
522
|
### getConnection
|
|
383
523
|
|
|
384
524
|
Get stored OIDC connection for a user and provider.
|
|
@@ -421,6 +561,22 @@ Delete/unlink an OIDC connection.
|
|
|
421
561
|
await ctx.plugins.oidc.deleteConnection(userId, "acme");
|
|
422
562
|
```
|
|
423
563
|
|
|
564
|
+
### invalidateProvider
|
|
565
|
+
|
|
566
|
+
Drop cached provider instance(s) so the next request re-runs `providerLoader` and OIDC discovery.
|
|
567
|
+
Call it whenever provider configuration changes at runtime.
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
// One provider
|
|
571
|
+
ctx.plugins.oidc.invalidateProvider("acme");
|
|
572
|
+
|
|
573
|
+
// All providers
|
|
574
|
+
ctx.plugins.oidc.invalidateProvider();
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Only affects the instance it runs on. For horizontally scaled apps, combine it with
|
|
578
|
+
[`providerCacheTtlSec`](#provider-config-caching) so the other instances converge within the TTL.
|
|
579
|
+
|
|
424
580
|
## Token Storage
|
|
425
581
|
|
|
426
582
|
### Auth-Only Mode (Default)
|
|
@@ -444,6 +600,9 @@ oidcPlugin({
|
|
|
444
600
|
- You don't need to call IdP APIs on behalf of users
|
|
445
601
|
- You want to minimize stored credentials
|
|
446
602
|
|
|
603
|
+
In this mode **no `oidc_connections` rows are written at all** — store the IdP
|
|
604
|
+
subject/issuer on your own user document if you need to look users up by it later.
|
|
605
|
+
|
|
447
606
|
### Token Storage Mode
|
|
448
607
|
|
|
449
608
|
Set `storeTokens: true` to store encrypted OIDC tokens for future API access.
|
|
@@ -623,6 +782,50 @@ oidcPlugin({
|
|
|
623
782
|
})
|
|
624
783
|
```
|
|
625
784
|
|
|
785
|
+
### Provider Config Caching
|
|
786
|
+
|
|
787
|
+
**The loader result is cached.** By default a provider is resolved once and reused for the
|
|
788
|
+
life of the process — editing an issuer, client id, client secret, scope or discovery URL
|
|
789
|
+
in the database has no effect until every instance restarts.
|
|
790
|
+
|
|
791
|
+
Set a TTL, and invalidate explicitly where the config is saved:
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
oidcPlugin({
|
|
795
|
+
providerLoader: async (providerName, ctx) => {
|
|
796
|
+
/* ... load from db ... */
|
|
797
|
+
},
|
|
798
|
+
|
|
799
|
+
// Re-resolve config + re-run discovery at most every 5 minutes
|
|
800
|
+
providerCacheTtlSec: 300,
|
|
801
|
+
|
|
802
|
+
onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
|
|
803
|
+
// ... JIT provisioning
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
```typescript
|
|
809
|
+
// src/handlers/admin/PutOidcProvider.ts — admin saves IdP settings
|
|
810
|
+
const handler: PutHandler<Ctx, OidcProviderConfigDB, void> = async ({ ctx, req }) => {
|
|
811
|
+
await ctx.repos.oidcProviderRepo.updateById(req.params.id, req.body);
|
|
812
|
+
|
|
813
|
+
// Takes effect immediately on this instance; others converge within providerCacheTtlSec
|
|
814
|
+
ctx.plugins.oidc.invalidateProvider(req.body.name);
|
|
815
|
+
|
|
816
|
+
return { status: 204 };
|
|
817
|
+
};
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Reloading is lazy — it happens on the next request for that provider, not on a timer. If
|
|
821
|
+
the reload fails (IdP unreachable, database hiccup) the previously cached instance keeps
|
|
822
|
+
being used and the next attempt happens after another TTL, so a discovery hiccup does not
|
|
823
|
+
break logins that would otherwise work. If the loader returns `null` (provider removed or
|
|
824
|
+
disabled), the cached instance is dropped and the request fails with `provider_not_configured`.
|
|
825
|
+
|
|
826
|
+
The TTL applies to static providers too: their discovery metadata (endpoints, JWKS URI) is
|
|
827
|
+
otherwise fetched once at first use and never refreshed.
|
|
828
|
+
|
|
626
829
|
### Database Schema for Dynamic Providers
|
|
627
830
|
|
|
628
831
|
```typescript
|
|
@@ -649,6 +852,47 @@ interface OidcProviderConfigDB {
|
|
|
649
852
|
|
|
650
853
|
## Security
|
|
651
854
|
|
|
855
|
+
### Redirect URI Allow-List
|
|
856
|
+
|
|
857
|
+
`GET /oidc/:provider/initiate?redirectUri=...` decides where the JWT ends up after login.
|
|
858
|
+
Restrict it to the URLs your own clients use:
|
|
859
|
+
|
|
860
|
+
```typescript
|
|
861
|
+
oidcPlugin({
|
|
862
|
+
// Exact URIs, bare origins, or a predicate
|
|
863
|
+
allowedRedirectUris: [
|
|
864
|
+
"https://app.example.com", // any path on this origin
|
|
865
|
+
"https://admin.example.com/auth-callback", // this exact URI only
|
|
866
|
+
"myapp://oidc-callback", // native app scheme
|
|
867
|
+
],
|
|
868
|
+
|
|
869
|
+
// ...or a predicate for anything else
|
|
870
|
+
// allowedRedirectUris: (redirectUri, provider) => redirectUri.endsWith(".example.com/cb"),
|
|
871
|
+
});
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Matching rules:
|
|
875
|
+
|
|
876
|
+
| Entry | Matches |
|
|
877
|
+
| --- | --- |
|
|
878
|
+
| `https://app.example.com` (no path) | Any path/query/fragment on that origin |
|
|
879
|
+
| `https://app.example.com/login` (has path) | That exact URI only |
|
|
880
|
+
| `myapp://cb` | `myapp://cb` and any path below it |
|
|
881
|
+
|
|
882
|
+
Always accepted regardless of the list:
|
|
883
|
+
|
|
884
|
+
- Relative paths (`/dashboard`) — they can only land on this app's own origin
|
|
885
|
+
- The provider's own `callbackUrl` (the fallback when no `redirectUri` is passed)
|
|
886
|
+
|
|
887
|
+
A `redirectUri` outside the list is rejected with `400 invalid_redirect_uri` and **no
|
|
888
|
+
session is created**. The same check is applied to a `redirectUrl` returned by
|
|
889
|
+
`onAuthSuccess` / `onAuthError` when it differs from the one stored on the session, so a
|
|
890
|
+
host that echoes an attacker-supplied value back cannot bypass the list by accident.
|
|
891
|
+
|
|
892
|
+
**If the option is omitted, every `redirectUri` is accepted** (unchanged legacy
|
|
893
|
+
behaviour) and the plugin logs a warning at startup. A future major version will make
|
|
894
|
+
the allow-list required.
|
|
895
|
+
|
|
652
896
|
### CSRF Protection
|
|
653
897
|
|
|
654
898
|
The plugin uses cryptographically secure state parameters to prevent CSRF attacks:
|
|
@@ -748,18 +992,24 @@ function LoginPage() {
|
|
|
748
992
|
|
|
749
993
|
### React Native App
|
|
750
994
|
|
|
995
|
+
Set [`tokenDelivery: "query"`](#token-delivery-fragment-vs-query) for native clients — iOS
|
|
996
|
+
`ASWebAuthenticationSession` strips the URL fragment before handing the callback URL to the
|
|
997
|
+
app, so `#token=...` never arrives.
|
|
998
|
+
|
|
751
999
|
```typescript
|
|
752
1000
|
import { openAuthSessionAsync } from "expo-auth-session";
|
|
753
1001
|
|
|
754
1002
|
async function loginWithOidc() {
|
|
755
|
-
const result = await openAuthSessionAsync(
|
|
1003
|
+
const result = await openAuthSessionAsync(
|
|
1004
|
+
"https://api.myapp.com/oidc/acme/initiate?redirectUri=myapp://oidc/callback",
|
|
1005
|
+
"myapp://oidc/callback"
|
|
1006
|
+
);
|
|
756
1007
|
|
|
757
1008
|
if (result.type === "success") {
|
|
758
|
-
const
|
|
1009
|
+
const urlObj = new URL(result.url);
|
|
759
1010
|
|
|
760
|
-
//
|
|
761
|
-
const
|
|
762
|
-
const token = new URLSearchParams(urlObj.hash.slice(1)).get("token");
|
|
1011
|
+
// With tokenDelivery: "query" the token arrives as a query parameter
|
|
1012
|
+
const token = urlObj.searchParams.get("token") || new URLSearchParams(urlObj.hash.slice(1)).get("token");
|
|
763
1013
|
|
|
764
1014
|
if (token) {
|
|
765
1015
|
await AsyncStorage.setItem("jwt_token", token);
|
|
@@ -769,6 +1019,9 @@ async function loginWithOidc() {
|
|
|
769
1019
|
}
|
|
770
1020
|
```
|
|
771
1021
|
|
|
1022
|
+
Remember to add the custom scheme to [`allowedRedirectUris`](#redirect-uri-allow-list):
|
|
1023
|
+
`allowedRedirectUris: ["https://app.example.com", "myapp://oidc/callback"]`.
|
|
1024
|
+
|
|
772
1025
|
## Troubleshooting
|
|
773
1026
|
|
|
774
1027
|
### OIDC Discovery Failed
|
|
@@ -781,6 +1034,24 @@ async function loginWithOidc() {
|
|
|
781
1034
|
- Check IdP is accessible from your server
|
|
782
1035
|
- Try manual endpoint configuration instead
|
|
783
1036
|
|
|
1037
|
+
### Invalid Redirect URI
|
|
1038
|
+
|
|
1039
|
+
**Issue:** `invalid_redirect_uri` error (400)
|
|
1040
|
+
|
|
1041
|
+
**Solution:**
|
|
1042
|
+
|
|
1043
|
+
- The `redirectUri` is not covered by `allowedRedirectUris` — add its origin or exact URI
|
|
1044
|
+
- Remember that an entry *with* a path matches that exact URI only; use the bare origin to cover every path below it
|
|
1045
|
+
- Check schemes and ports match exactly (`https://app.example.com` does not cover `http://` or `:8443`)
|
|
1046
|
+
|
|
1047
|
+
### JSON Mode Not Available
|
|
1048
|
+
|
|
1049
|
+
**Issue:** `invalid_response_type` error (400) on `?response_type=json`
|
|
1050
|
+
|
|
1051
|
+
**Solution:**
|
|
1052
|
+
|
|
1053
|
+
- The application completed the login without a token (it returned `{ user, redirectUrl }`), so there is nothing to put in a JSON body — drop `response_type=json`, or return `{ user, response }` and build the JSON yourself
|
|
1054
|
+
|
|
784
1055
|
### Invalid State Parameter
|
|
785
1056
|
|
|
786
1057
|
**Issue:** `invalid_state` error
|
|
@@ -833,11 +1104,19 @@ import {
|
|
|
833
1104
|
OidcConnection,
|
|
834
1105
|
OidcError,
|
|
835
1106
|
OidcPluginContext,
|
|
1107
|
+
AllowedRedirectUris,
|
|
1108
|
+
AuthSuccessCallbackResponse,
|
|
1109
|
+
AuthSuccessTokenResponse,
|
|
1110
|
+
AuthSuccessRedirectResponse,
|
|
1111
|
+
AuthSuccessCustomResponse,
|
|
1112
|
+
AuthErrorCallbackResponse,
|
|
836
1113
|
} from "@flink-app/oidc-plugin";
|
|
837
1114
|
```
|
|
838
1115
|
|
|
839
1116
|
## Production Checklist
|
|
840
1117
|
|
|
1118
|
+
- [ ] Set `allowedRedirectUris` to the URLs your clients actually use
|
|
1119
|
+
- [ ] Set `providerCacheTtlSec` if provider configs live in a database (otherwise edits need a restart)
|
|
841
1120
|
- [ ] Configure HTTPS for all OIDC callback URLs
|
|
842
1121
|
- [ ] Set OIDC credentials in secure environment variables
|
|
843
1122
|
- [ ] Configure JWT Auth Plugin with secure secret
|
package/dist/OidcPlugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OidcPlugin.d.ts","sourceRoot":"","sources":["../src/OidcPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAO,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAWxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAG,GAAG,EAAE,OAAO,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"OidcPlugin.d.ts","sourceRoot":"","sources":["../src/OidcPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAO,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAWxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAG,GAAG,EAAE,OAAO,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAAG,WAAW,CAgRpF"}
|
package/dist/OidcPlugin.js
CHANGED
|
@@ -141,6 +141,25 @@ function oidcPlugin(options) {
|
|
|
141
141
|
if (!options.onAuthSuccess) {
|
|
142
142
|
throw new Error("OIDC Plugin: onAuthSuccess callback is required");
|
|
143
143
|
}
|
|
144
|
+
// Redirect URI allow-list. Absent = every redirectUri is accepted (legacy behaviour),
|
|
145
|
+
// which lets a crafted initiate link deliver the app's JWT to any URL.
|
|
146
|
+
if (options.allowedRedirectUris === undefined) {
|
|
147
|
+
flink_1.log.warn("OIDC Plugin: allowedRedirectUris is not set — any redirectUri passed to /initiate is accepted. Set it to the URLs your clients use.");
|
|
148
|
+
}
|
|
149
|
+
else if (typeof options.allowedRedirectUris !== "function") {
|
|
150
|
+
if (!Array.isArray(options.allowedRedirectUris) || options.allowedRedirectUris.some((entry) => typeof entry !== "string" || !entry)) {
|
|
151
|
+
throw new Error("OIDC Plugin: allowedRedirectUris must be an array of non-empty strings or a predicate function");
|
|
152
|
+
}
|
|
153
|
+
log_1.oidcLog.debug(`Redirect URI allow-list: [${options.allowedRedirectUris.join(", ")}]`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
log_1.oidcLog.debug("Redirect URI allow-list: predicate function");
|
|
157
|
+
}
|
|
158
|
+
// Provider cache TTL (0 = cache for the life of the process, unchanged behaviour)
|
|
159
|
+
const providerCacheTtlSec = options.providerCacheTtlSec ?? 0;
|
|
160
|
+
if (typeof providerCacheTtlSec !== "number" || !Number.isFinite(providerCacheTtlSec) || providerCacheTtlSec < 0) {
|
|
161
|
+
throw new Error("OIDC Plugin: providerCacheTtlSec must be a non-negative number of seconds");
|
|
162
|
+
}
|
|
144
163
|
// Determine encryption key
|
|
145
164
|
let encryptionKey = options.encryptionKey;
|
|
146
165
|
if (!encryptionKey) {
|
|
@@ -187,7 +206,7 @@ function oidcPlugin(options) {
|
|
|
187
206
|
// Initialize repositories
|
|
188
207
|
const sessionsCollectionName = options.sessionsCollectionName || "oidc_sessions";
|
|
189
208
|
const connectionsCollectionName = options.connectionsCollectionName || "oidc_connections";
|
|
190
|
-
log_1.oidcLog.debug(`Init: sessionsCollection=${sessionsCollectionName}`, `connectionsCollection=${connectionsCollectionName}`, `storeTokens=${!!options.storeTokens}`, `sessionTTL=${options.sessionTTL ?? 600}s`, `registerRoutes=${options.registerRoutes !== false}`, configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)", options.providerLoader ? "providerLoader=yes" : "providerLoader=no");
|
|
209
|
+
log_1.oidcLog.debug(`Init: sessionsCollection=${sessionsCollectionName}`, `connectionsCollection=${connectionsCollectionName}`, `storeTokens=${!!options.storeTokens}`, `sessionTTL=${options.sessionTTL ?? 600}s`, `providerCacheTtl=${providerCacheTtlSec > 0 ? `${providerCacheTtlSec}s` : "forever"}`, `registerRoutes=${options.registerRoutes !== false}`, configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)", options.providerLoader ? "providerLoader=yes" : "providerLoader=no");
|
|
191
210
|
sessionRepo = new OidcSessionRepo_1.default(sessionsCollectionName, db);
|
|
192
211
|
connectionRepo = new OidcConnectionRepo_1.default(connectionsCollectionName, db);
|
|
193
212
|
flinkApp.addRepo("oidcSessionRepo", sessionRepo);
|
|
@@ -197,7 +216,7 @@ function oidcPlugin(options) {
|
|
|
197
216
|
await db.collection(sessionsCollectionName).createIndex({ createdAt: 1 }, { expireAfterSeconds: sessionTTL });
|
|
198
217
|
flink_1.log.info(`OIDC Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`);
|
|
199
218
|
// Initialize provider registry
|
|
200
|
-
providerRegistry = new ProviderRegistry_1.ProviderRegistry(providers, options.providerLoader, flinkApp.ctx);
|
|
219
|
+
providerRegistry = new ProviderRegistry_1.ProviderRegistry(providers, options.providerLoader, flinkApp.ctx, providerCacheTtlSec);
|
|
201
220
|
// Store provider registry in app context for handlers to access
|
|
202
221
|
flinkApp.ctx.oidcProviderRegistry = providerRegistry;
|
|
203
222
|
// Register OIDC handlers
|
|
@@ -268,6 +287,19 @@ function oidcPlugin(options) {
|
|
|
268
287
|
await connectionRepo.deleteByUserAndProvider(userId, provider);
|
|
269
288
|
flink_1.log.info(`OIDC Plugin: Deleted ${provider} connection for user ${userId}`);
|
|
270
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Drop cached provider instance(s) so the next request re-runs providerLoader
|
|
292
|
+
* and OIDC discovery
|
|
293
|
+
*/
|
|
294
|
+
function invalidateProvider(providerName) {
|
|
295
|
+
if (!providerRegistry) {
|
|
296
|
+
// Nothing is cached before init, so there is nothing to invalidate
|
|
297
|
+
log_1.oidcLog.debug("invalidateProvider called before plugin init, nothing to do");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
providerRegistry.clearCache(providerName);
|
|
301
|
+
flink_1.log.info(`OIDC Plugin: invalidated provider cache for ${providerName ? `"${providerName}"` : "all providers"}`);
|
|
302
|
+
}
|
|
271
303
|
/**
|
|
272
304
|
* Plugin context exposed via ctx.plugins.oidc
|
|
273
305
|
*/
|
|
@@ -275,6 +307,7 @@ function oidcPlugin(options) {
|
|
|
275
307
|
getConnection,
|
|
276
308
|
getConnections,
|
|
277
309
|
deleteConnection,
|
|
310
|
+
invalidateProvider,
|
|
278
311
|
options: Object.freeze({ ...options }),
|
|
279
312
|
};
|
|
280
313
|
return {
|
|
@@ -63,6 +63,23 @@ export interface OidcPluginContext {
|
|
|
63
63
|
* ```
|
|
64
64
|
*/
|
|
65
65
|
deleteConnection: (userId: string, provider: string) => Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Drop cached provider instance(s) so the next request re-runs `providerLoader`
|
|
68
|
+
* and OIDC discovery for them.
|
|
69
|
+
*
|
|
70
|
+
* Call this when provider configuration changes at runtime, e.g. from the handler
|
|
71
|
+
* where an admin saves IdP settings. Only affects the instance it runs on — with
|
|
72
|
+
* `providerCacheTtlSec` set, other instances converge within the TTL.
|
|
73
|
+
*
|
|
74
|
+
* @param providerName - Provider to invalidate (all providers if omitted)
|
|
75
|
+
*
|
|
76
|
+
* Example:
|
|
77
|
+
* ```typescript
|
|
78
|
+
* await ctx.repos.oidcProviderRepo.updateById(id, { clientSecret });
|
|
79
|
+
* ctx.plugins.oidc.invalidateProvider(providerName);
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
invalidateProvider: (providerName?: string) => void;
|
|
66
83
|
/**
|
|
67
84
|
* Plugin configuration options (read-only)
|
|
68
85
|
* Useful for checking plugin settings at runtime
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OidcPluginContext.d.ts","sourceRoot":"","sources":["../src/OidcPluginContext.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE;QACF;;;;;;;;;;;;;;;;;;;;WAoBG;QACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;QAEpF;;;;;;;;;;;;;;;;WAgBG;QACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;QAE9D;;;;;;;;;;;;;;WAcG;QACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAEtE;;;WAGG;QACH,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;KACxC,CAAC;CACL"}
|
|
1
|
+
{"version":3,"file":"OidcPluginContext.d.ts","sourceRoot":"","sources":["../src/OidcPluginContext.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE;QACF;;;;;;;;;;;;;;;;;;;;WAoBG;QACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;QAEpF;;;;;;;;;;;;;;;;WAgBG;QACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;QAE9D;;;;;;;;;;;;;;WAcG;QACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAEtE;;;;;;;;;;;;;;;WAeG;QACH,kBAAkB,EAAE,CAAC,YAAY,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;QAEpD;;;WAGG;QACH,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;KACxC,CAAC;CACL"}
|