Unified · Split
| @@ -0,0 +1,23 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | OAuth token material is now carried as `Redacted<string>`. `OAuth2TokenResponse` | ||
| 6 | returns `access_token` and `refresh_token` wrapped, the PKCE code verifier and | ||
| 7 | the RFC 6749 `state` are wrapped for their in-memory life, and a DCR response's | ||
| 8 | `client_secret` / `registration_access_token` are wrapped as they are decoded. | ||
| 9 | The unwraps are confined to the oauth4webapi call boundary, the credential | ||
| 10 | provider write, and the `oauth_session.pkce_verifier` column. | ||
| 11 | |||
| 12 | Inputs are widened, not narrowed: `clientSecret`, `code`, `codeVerifier`, | ||
| 13 | `refreshToken`, and the DCR `initialAccessToken` all accept | ||
| 14 | `string | Redacted<string>`, so existing callers are unaffected. `OAuthClient. | ||
| 15 | clientSecret` accepts either as well; `null` remains the only spelling of | ||
| 16 | "public / PKCE client", and `oauthClientSecretFromInput` now tests emptiness on | ||
| 17 | the unwrapped value, since every `Redacted` is truthy. | ||
| 18 | |||
| 19 | Also fixes a leak this surfaced: `OAuth2Error.cause` carried the token | ||
| 20 | endpoint's parsed error body verbatim, so a rejected grant republished the | ||
| 21 | credentials the endpoint echoed back into anything that serialized the failure | ||
| 22 | (a log line, an error payload). The cause is now redacted structurally, keeping | ||
| 23 | the RFC 6749 `error` / `error_description` diagnostics. | ||
| @@ -1531,17 +1531,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1530 unmodified lines | |||
| 1531 | // Null means "public client, send no client_secret" — both for a row | 1531 | // Null means "public client, send no client_secret" — both for a row |
| 1532 | // that never had one and for a confidential row whose vault item is | 1532 | // that never had one and for a confidential row whose vault item is |
| 1533 | // gone, which the AS then rejects loudly instead of us guessing. | 1533 | // gone, which the AS then rejects loudly instead of us guessing. |
| 1534 | // Boundary: the OAuth 2.1 helpers take a bare `client_secret` (it is | 1534 | // It stays wrapped all the way to the OAuth 2.1 helpers, which unwrap |
| 1535 | // posted to the token endpoint by oauth4webapi). Unwrap BEFORE | 1535 | // at the oauth4webapi call. Presence is normalized on the UNWRAPPED |
| 1536 | // presence-normalizing — `Redacted.make("")` is truthy, so an emptiness | 1536 | // value — `Redacted.make("")` is truthy, so an emptiness test on the |
| 1537 | // test on the wrapper would read a public client as confidential. | 1537 | // wrapper would read a public client as confidential. |
| 1538 | const storedClientSecret = | 1538 | const storedClientSecret = |
| 1539 | clientRow.client_secret_item_id == null | 1539 | clientRow.client_secret_item_id == null |
| 1540 | ? null | 1540 | ? null |
| 1541 | : yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id))); | 1541 | : yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id))); |
| 1542 | const clientSecret = oauthClientSecretFromInput( | 1542 | const clientSecret = |
| 1543 | storedClientSecret === null ? null : Redacted.value(storedClientSecret), | 1543 | storedClientSecret === null ? null : oauthClientSecretFromInput(storedClientSecret); |
| 1544 | ); | ||
| 1545 | // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a | 1544 | // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a |
| 1546 | // refresh must not exceed the originally-granted scope). Empty → omit | 1545 | // refresh must not exceed the originally-granted scope). Empty → omit |
| 1547 | // the param, which the AS treats as "same scopes as granted". | 1546 | // the param, which the AS treats as "same scopes as granted". |
| @@ -1600,9 +1599,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 52 unmodified lines | |||
| 1600 | tokenUrl, | 1599 | tokenUrl, |
| 1601 | clientId: String(clientRow.client_id), | 1600 | clientId: String(clientRow.client_id), |
| 1602 | clientSecret, | 1601 | clientSecret, |
| 1603 | // Boundary: the refresh-token grant posts the token as a form | 1602 | refreshToken, |
| 1604 | // field, so the helper takes it bare. | ||
| 1605 | refreshToken: Redacted.value(refreshToken), | ||
| 1606 | scopes: grantedScopes, | 1603 | scopes: grantedScopes, |
| 1607 | // RFC 8707: keep the re-minted token bound to the same resource | 1604 | // RFC 8707: keep the re-minted token bound to the same resource |
| 1608 | // (MCP servers require this on refresh). | 1605 | // (MCP servers require this on refresh). |
| @@ -1647,8 +1644,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 38 unmodified lines | |||
| 1647 | const tokenItemId = | 1644 | const tokenItemId = |
| 1648 | connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? | 1645 | connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? |
| 1649 | `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`; | 1646 | `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`; |
| 1647 | // `provider.set` accepts `Redacted` and unwraps at its own write line. | ||
| 1650 | yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token); | 1648 | yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token); |
| 1651 | if (token.refresh_token && row.refresh_item_id) { | 1649 | // Presence, not truthiness — every `Redacted` is truthy. |
| 1650 | if (token.refresh_token !== undefined && row.refresh_item_id) { | ||
| 1652 | yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token); | 1651 | yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token); |
| 1653 | } | 1652 | } |
| 1654 | } | 1653 | } |
| @@ -1670,7 +1669,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 15 unmodified lines | |||
| 1670 | set, | 1669 | set, |
| 1671 | }); | 1670 | }); |
| 1672 | 1671 | ||
| 1673 | return Redacted.make(token.access_token); | 1672 | return token.access_token; |
| 1674 | }); | 1673 | }); |
| 1675 | 1674 | ||
| 1676 | const refreshConnectionToken = ( | 1675 | const refreshConnectionToken = ( |
| @@ -1,5 +1,5 @@ | |||
| 1 | import type { Effect } from "effect"; | 1 | import type { Effect } from "effect"; |
| 2 | import { Schema } from "effect"; | 2 | import { Redacted, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import type { Connection } from "./connection"; | 4 | import type { Connection } from "./connection"; |
| 5 | import type { UserActionableError } from "./errors"; | 5 | import type { UserActionableError } from "./errors"; |
| @@ -56,24 +56,40 @@ export interface OAuthClient { · 50 unmodified lines | |||
| 56 | readonly tokenUrl: string; | 56 | readonly tokenUrl: string; |
| 57 | readonly grant: OAuthGrant; | 57 | readonly grant: OAuthGrant; |
| 58 | readonly clientId: string; | 58 | readonly clientId: string; |
| 59 | /** The literal client secret, or `null` for a public / PKCE client. Stored | 59 | /** The client secret, or `null` for a public / PKCE client. Stored out-of-band |
| 60 | * out-of-band in the credential provider (vault item id), never inline. | 60 | * in the credential provider (vault item id), never inline. Accepts a bare |
| 61 | * Presence is `null`, never `""`: an empty string is a value, so any | 61 | * string as well as `Redacted` so a pasted form field needs no wrapping; |
| 62 | * falsiness or emptiness test on it is a bug waiting to happen. Convert | 62 | * everything downstream of `createClient` carries it wrapped. |
| 63 | * form/wire input through `clientSecretFromInput` at the boundary. */ | 63 | * Presence is `null`, never `""` and never `Redacted.make("")`: an empty |
| 64 | readonly clientSecret: string | null; | 64 | * string is a value and every `Redacted` is truthy, so any falsiness or |
| 65 | * emptiness test on it is a bug waiting to happen. Convert form/wire input | ||
| 66 | * through `oauthClientSecretFromInput` at the boundary. */ | ||
| 67 | readonly clientSecret: string | Redacted.Redacted<string> | null; | ||
| 65 | /** RFC 8707 Resource Indicator (MCP). Carried so the refresh request can keep | 68 | /** RFC 8707 Resource Indicator (MCP). Carried so the refresh request can keep |
| 66 | * the re-minted token bound to the same resource. Null/omitted otherwise. */ | 69 | * the re-minted token bound to the same resource. Null/omitted otherwise. */ |
| 67 | readonly resource?: string | null; | 70 | readonly resource?: string | null; |
| 68 | } | 71 | } |
| 69 | 72 | ||
| 70 | /** Presence-normalize a client secret arriving from a form field or the wire, | 73 | /** Presence-normalize a client secret arriving from a form field, the wire, or a |
| 71 | * where "absent" can only be spelled as the empty string. Deliberately does NOT | 74 | * DCR response, where "absent" can only be spelled as the empty string. |
| 72 | * trim: a whitespace-bearing secret is a value, and trimming it here would | 75 | * Emptiness is tested on the UNWRAPPED value: every `Redacted` is truthy, so a |
| 73 | * silently downgrade a confidential client to a public one. Callers that want | 76 | * wrapped `""` would otherwise register a confidential client with an empty |
| 74 | * the user's leading/trailing whitespace dropped trim before calling. */ | 77 | * secret. The wrapper is preserved — normalizing does not unwrap. |
| 75 | export const oauthClientSecretFromInput = (value: string | null | undefined): string | null => | 78 | * Deliberately does NOT trim: a whitespace-bearing secret is a value, and |
| 76 | value == null || value.length === 0 ? null : value; | 79 | * trimming it here would silently downgrade a confidential client to a public |
| 80 | * one. Callers that want the user's leading/trailing whitespace dropped trim | ||
| 81 | * before calling. */ | ||
| 82 | export function oauthClientSecretFromInput(value: string | null | undefined): string | null; | ||
| 83 | export function oauthClientSecretFromInput<A extends string | Redacted.Redacted<string>>( | ||
| 84 | value: A | null | undefined, | ||
| 85 | ): A | null; | ||
| 86 | export function oauthClientSecretFromInput( | ||
| 87 | value: string | Redacted.Redacted<string> | null | undefined, | ||
| 88 | ): string | Redacted.Redacted<string> | null { | ||
| 89 | if (value == null) return null; | ||
| 90 | const literal = Redacted.isRedacted(value) ? Redacted.value(value) : value; | ||
| 91 | return literal.length === 0 ? null : value; | ||
| 92 | } | ||
| 77 | 93 | ||
| 78 | export type OAuthClientOrigin = | 94 | export type OAuthClientOrigin = |
| 79 | | { | 95 | | { |
| @@ -18,7 +18,18 @@ · 17 unmodified lines | |||
| 18 | // call callers actually need. | 18 | // call callers actually need. |
| 19 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 20 | 20 | ||
| 21 | import { Data, Duration, Effect, Layer, Option, Predicate, Result, Schema } from "effect"; | 21 | import { |
| 22 | Data, | ||
| 23 | Duration, | ||
| 24 | Effect, | ||
| 25 | Layer, | ||
| 26 | Option, | ||
| 27 | Predicate, | ||
| 28 | Redacted, | ||
| 29 | Result, | ||
| 30 | Schema, | ||
| 31 | SchemaGetter, | ||
| 32 | } from "effect"; | ||
| 22 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | 33 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 23 | 34 | ||
| 24 | import { | 35 | import { |
| @@ -27,6 +38,7 @@ import { · 2 unmodified lines | |||
| 27 | buildAuthorizationUrl, | 38 | buildAuthorizationUrl, |
| 28 | createPkceCodeChallenge, | 39 | createPkceCodeChallenge, |
| 29 | createPkceCodeVerifier, | 40 | createPkceCodeVerifier, |
| 41 | type OAuth2SecretInput, | ||
| 30 | type OAuthEndpointUrlPolicy, | 42 | type OAuthEndpointUrlPolicy, |
| 31 | } from "./oauth-helpers"; | 43 | } from "./oauth-helpers"; |
| 32 | 44 | ||
| @@ -106,12 +118,28 @@ export type DynamicClientMetadata = { · 73 unmodified lines | |||
| 106 | readonly extra?: Readonly<Record<string, unknown>>; | 118 | readonly extra?: Readonly<Record<string, unknown>>; |
| 107 | }; | 119 | }; |
| 108 | 120 | ||
| 121 | /** A bidirectional string ⇄ `Redacted<string>` codec. `Schema.Redacted` and | ||
| 122 | * `Schema.RedactedFromValue` forbid encoding, which would make any struct | ||
| 123 | * carrying one decode-only; DCR client information round-trips (it is cached | ||
| 124 | * and replayed through `previousState`), so the encode side is required. */ | ||
| 125 | const RedactedString = Schema.String.pipe( | ||
| 126 | Schema.decodeTo(Schema.Redacted(Schema.String), { | ||
| 127 | decode: SchemaGetter.transform(Redacted.make<string>), | ||
| 128 | encode: SchemaGetter.transform(Redacted.value<string>), | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | |||
| 109 | export const OAuthClientInformationSchema = Schema.Struct({ | 132 | export const OAuthClientInformationSchema = Schema.Struct({ |
| 110 | client_id: Schema.String, | 133 | client_id: Schema.String, |
| 111 | client_secret: Schema.optional(Schema.String), | 134 | /** Minted by the AS for a confidential DCR client. Redacted from the moment |
| 135 | * the registration response is decoded — it is posted to the token endpoint | ||
| 136 | * and stored in the credential provider, and reaches nothing else. */ | ||
| 137 | client_secret: Schema.optional(RedactedString), | ||
| 112 | client_id_issued_at: Schema.optional(Schema.Number), | 138 | client_id_issued_at: Schema.optional(Schema.Number), |
| 113 | client_secret_expires_at: Schema.optional(Schema.Number), | 139 | client_secret_expires_at: Schema.optional(Schema.Number), |
| 114 | registration_access_token: Schema.optional(Schema.String), | 140 | /** RFC 7592 bearer token for the client's own registration resource — as |
| 141 | * privileged as the client secret. */ | ||
| 142 | registration_access_token: Schema.optional(RedactedString), | ||
| 115 | registration_client_uri: Schema.optional(Schema.String), | 143 | registration_client_uri: Schema.optional(Schema.String), |
| 116 | token_endpoint_auth_method: Schema.optional(Schema.String), | 144 | token_endpoint_auth_method: Schema.optional(Schema.String), |
| 117 | grant_types: Schema.optional(StringArray), | 145 | grant_types: Schema.optional(StringArray), |
| @@ -413,7 +441,7 @@ export const discoverAuthorizationServerMetadata = ( · 295 unmodified lines | |||
| 413 | export interface RegisterDynamicClientInput { | 441 | export interface RegisterDynamicClientInput { |
| 414 | readonly registrationEndpoint: string; | 442 | readonly registrationEndpoint: string; |
| 415 | readonly metadata: DynamicClientMetadata; | 443 | readonly metadata: DynamicClientMetadata; |
| 416 | readonly initialAccessToken?: string | null; | 444 | readonly initialAccessToken?: OAuth2SecretInput | null; |
| 417 | } | 445 | } |
| 418 | 446 | ||
| 419 | // Internal failure modes — collapsed into `OAuthDiscoveryError` at the | 447 | // Internal failure modes — collapsed into `OAuthDiscoveryError` at the |
| @@ -513,8 +541,12 @@ export const registerDynamicClient = ( · 93 unmodified lines | |||
| 513 | "content-type": "application/json", | 541 | "content-type": "application/json", |
| 514 | accept: "application/json", | 542 | accept: "application/json", |
| 515 | }; | 543 | }; |
| 516 | if (input.initialAccessToken) { | 544 | if (input.initialAccessToken != null) { |
| 517 | headers.authorization = `Bearer ${input.initialAccessToken}`; | 545 | // Boundary: the RFC 7591 initial access token goes out as a bearer header. |
| 546 | const token = Redacted.isRedacted(input.initialAccessToken) | ||
| 547 | ? Redacted.value(input.initialAccessToken) | ||
| 548 | : input.initialAccessToken; | ||
| 549 | if (token.length > 0) headers.authorization = `Bearer ${token}`; | ||
| 518 | } | 550 | } |
| 519 | 551 | ||
| 520 | let request = HttpClientRequest.post(input.registrationEndpoint).pipe( | 552 | let request = HttpClientRequest.post(input.registrationEndpoint).pipe( |
| @@ -661,7 +693,9 @@ export interface DynamicAuthorizationState { · 140 unmodified lines | |||
| 661 | 693 | ||
| 662 | export interface DynamicAuthorizationStartResult { | 694 | export interface DynamicAuthorizationStartResult { |
| 663 | readonly authorizationUrl: string; | 695 | readonly authorizationUrl: string; |
| 664 | readonly codeVerifier: string; | 696 | /** Grant material — the caller holds it until the matching token exchange. |
| 697 | * Unwrap only at that exchange or at the line that persists it. */ | ||
| 698 | readonly codeVerifier: Redacted.Redacted<string>; | ||
| 665 | readonly state: DynamicAuthorizationState; | 699 | readonly state: DynamicAuthorizationState; |
| 666 | } | 700 | } |
| 667 | 701 | ||
| @@ -1331,6 +1331,155 @@ describe("oauth client secret presence", () => { · 1330 unmodified lines | |||
| 1331 | ); | 1331 | ); |
| 1332 | }); | 1332 | }); |
| 1333 | 1333 | ||
| 1334 | // --------------------------------------------------------------------------- | ||
| 1335 | // Persisted bytes. | ||
| 1336 | // | ||
| 1337 | // A missed unwrap on a WRITE path does not throw: `Redacted`'s toString/toJSON | ||
| 1338 | // render "<redacted>", so the literal is stored and the failure only surfaces | ||
| 1339 | // later, as an authorization error against a provider. These assert the stored | ||
| 1340 | // value, not the in-memory one — the in-memory half is covered in | ||
| 1341 | // `oauth-helpers.test.ts`. | ||
| 1342 | // --------------------------------------------------------------------------- | ||
| 1343 | |||
| 1344 | describe("OAuth material persists as its value, never as the redaction marker", () => { | ||
| 1345 | it.effect("the minted access + refresh tokens and the PKCE verifier store literally", () => | ||
| 1346 | Effect.scoped( | ||
| 1347 | Effect.gen(function* () { | ||
| 1348 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 1349 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 1350 | yield* executor.acme.seed(["read"]); | ||
| 1351 | |||
| 1352 | yield* executor.oauth.createClient({ | ||
| 1353 | owner: "org", | ||
| 1354 | slug: CLIENT, | ||
| 1355 | authorizationUrl: server.authorizationEndpoint, | ||
| 1356 | tokenUrl: server.tokenEndpoint, | ||
| 1357 | grant: "authorization_code", | ||
| 1358 | clientId: "test-client", | ||
| 1359 | clientSecret: "test-secret", | ||
| 1360 | }); | ||
| 1361 | |||
| 1362 | const started = yield* executor.oauth.start({ | ||
| 1363 | owner: "org", | ||
| 1364 | client: CLIENT, | ||
| 1365 | clientOwner: "org", | ||
| 1366 | name: ConnectionName.make("main"), | ||
| 1367 | integration: INTEG, | ||
| 1368 | template: TEMPLATE, | ||
| 1369 | }); | ||
| 1370 | expect(started.status).toBe("redirect"); | ||
| 1371 | if (started.status !== "redirect") return; | ||
| 1372 | |||
| 1373 | // The session's PKCE verifier is written by `start`. If it stored | ||
| 1374 | // "<redacted>" the exchange below would fail RFC 7636 validation — but | ||
| 1375 | // assert the column too, so the reason is legible when it does. | ||
| 1376 | const sessionRow = yield* Effect.promise(() => | ||
| 1377 | config.db.findFirst("oauth_session", { | ||
| 1378 | where: (b) => b("state", "=", String(started.state)), | ||
| 1379 | }), | ||
| 1380 | ); | ||
| 1381 | const storedVerifier = String(sessionRow?.pkce_verifier); | ||
| 1382 | expect(storedVerifier).not.toBe("<redacted>"); | ||
| 1383 | expect(storedVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/); | ||
| 1384 | |||
| 1385 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1386 | authorizationUrl: started.authorizationUrl, | ||
| 1387 | }); | ||
| 1388 | yield* executor.oauth.complete({ state: started.state, code: callback.code }); | ||
| 1389 | |||
| 1390 | // The stored access token is the one the AS issued: the tool echoes the | ||
| 1391 | // resolved credential, and the server still honours it. | ||
| 1392 | const echoed = (yield* executor.execute( | ||
| 1393 | ToolAddress.make("tools.acme.org.main.whoami"), | ||
| 1394 | {}, | ||
| 1395 | )) as { token: string }; | ||
| 1396 | expect(echoed.token).not.toBe("<redacted>"); | ||
| 1397 | expect(echoed.token).toMatch(/^at_/); | ||
| 1398 | expect(yield* server.acceptsAccessToken(echoed.token)).toBe(true); | ||
| 1399 | |||
| 1400 | // The refresh token is stored under its own item id and must be equally | ||
| 1401 | // literal — nothing reads it until a refresh, so a "<redacted>" write | ||
| 1402 | // here would lie dormant until the access token first expired. | ||
| 1403 | const connectionRow = yield* Effect.promise(() => | ||
| 1404 | config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }), | ||
| 1405 | ); | ||
| 1406 | expect(connectionRow?.refresh_item_id ?? null).not.toBeNull(); | ||
| 1407 | |||
| 1408 | // Force expiry so the refresh grant runs against the stored refresh | ||
| 1409 | // token. The AS matches it by exact string, so a grant that succeeds is | ||
| 1410 | // proof the token round-tripped through the provider byte for byte. | ||
| 1411 | yield* Effect.promise(() => | ||
| 1412 | config.db.updateMany("connection", { | ||
| 1413 | where: (b) => b("name", "=", "main"), | ||
| 1414 | set: { expires_at: Date.now() - 60_000 }, | ||
| 1415 | }), | ||
| 1416 | ); | ||
| 1417 | const refreshed = (yield* executor.execute( | ||
| 1418 | ToolAddress.make("tools.acme.org.main.whoami"), | ||
| 1419 | {}, | ||
| 1420 | )) as { token: string }; | ||
| 1421 | expect(refreshed.token).not.toBe("<redacted>"); | ||
| 1422 | expect(refreshed.token).toMatch(/^at_/); | ||
| 1423 | expect(refreshed.token).not.toBe(echoed.token); | ||
| 1424 | expect(yield* server.acceptsAccessToken(refreshed.token)).toBe(true); | ||
| 1425 | }), | ||
| 1426 | ), | ||
| 1427 | ); | ||
| 1428 | |||
| 1429 | it.effect("a DCR-minted client secret authenticates instead of storing <redacted>", () => | ||
| 1430 | // The DCR secret is decoded straight out of the registration response into | ||
| 1431 | // a `Redacted` field, then written to the provider and later posted to the | ||
| 1432 | // token endpoint. A missed unwrap anywhere on that path stores the marker | ||
| 1433 | // and the AS rejects the grant with `invalid_client`. | ||
| 1434 | Effect.scoped( | ||
| 1435 | Effect.gen(function* () { | ||
| 1436 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 1437 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 1438 | yield* executor.acme.seed(["read"]); | ||
| 1439 | |||
| 1440 | const slug = yield* executor.oauth.registerDynamicClient({ | ||
| 1441 | owner: "org", | ||
| 1442 | slug: CLIENT, | ||
| 1443 | issuer: server.issuerUrl, | ||
| 1444 | registrationEndpoint: server.registrationEndpoint, | ||
| 1445 | authorizationUrl: server.authorizationEndpoint, | ||
| 1446 | tokenUrl: server.tokenEndpoint, | ||
| 1447 | scopes: ["read"], | ||
| 1448 | // Advertising no `none` makes DCR register a CONFIDENTIAL client, so | ||
| 1449 | // the server mints a secret we must carry back to /token. | ||
| 1450 | tokenEndpointAuthMethodsSupported: ["client_secret_post"], | ||
| 1451 | }); | ||
| 1452 | |||
| 1453 | const row = yield* Effect.promise(() => | ||
| 1454 | config.db.findFirst("oauth_client", { where: (b) => b("slug", "=", String(slug)) }), | ||
| 1455 | ); | ||
| 1456 | expect(row?.client_secret_item_id ?? null).not.toBeNull(); | ||
| 1457 | |||
| 1458 | const started = yield* executor.oauth.start({ | ||
| 1459 | owner: "org", | ||
| 1460 | client: slug, | ||
| 1461 | clientOwner: "org", | ||
| 1462 | name: ConnectionName.make("main"), | ||
| 1463 | integration: INTEG, | ||
| 1464 | template: TEMPLATE, | ||
| 1465 | }); | ||
| 1466 | expect(started.status).toBe("redirect"); | ||
| 1467 | if (started.status !== "redirect") return; | ||
| 1468 | const callback = yield* server.completeAuthorizationCodeFlow({ | ||
| 1469 | authorizationUrl: started.authorizationUrl, | ||
| 1470 | }); | ||
| 1471 | // The exchange succeeding IS the assertion: the test server rejects a | ||
| 1472 | // mismatched client_secret with invalid_client. | ||
| 1473 | yield* executor.oauth.complete({ state: started.state, code: callback.code }); | ||
| 1474 | |||
| 1475 | const grant = tokenGrantBodies(yield* server.requests).at(0); | ||
| 1476 | expect(grant?.get("client_secret")).not.toBe("<redacted>"); | ||
| 1477 | expect(grant?.get("client_secret") ?? "").not.toBe(""); | ||
| 1478 | }), | ||
| 1479 | ), | ||
| 1480 | ); | ||
| 1481 | }); | ||
| 1482 | |||
| 1334 | describe("oauth.probe error message sanitization", () => { | 1483 | describe("oauth.probe error message sanitization", () => { |
| 1335 | it.effect("echoes the probed URL without its query string or userinfo", () => | 1484 | it.effect("echoes the probed URL without its query string or userinfo", () => |
| 1336 | // The probe URL is a raw user paste and a supported credential carrier (an | 1485 | // The probe URL is a raw user paste and a supported credential carrier (an |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | |||
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import { Effect, Exit, Ref } from "effect"; | 9 | import { Effect, Exit, Logger, Redacted, Ref } from "effect"; |
| 10 | import { HttpServerResponse } from "effect/unstable/http"; | 10 | import { HttpServerResponse } from "effect/unstable/http"; |
| 11 | 11 | ||
| 12 | import { | 12 | import { |
| @@ -15,6 +15,7 @@ import { · 2 unmodified lines | |||
| 15 | OAuth2Error, | 15 | OAuth2Error, |
| 16 | buildAuthorizationUrl, | 16 | buildAuthorizationUrl, |
| 17 | providerAuthorizeExtras, | 17 | providerAuthorizeExtras, |
| 18 | createOAuthState, | ||
| 18 | createPkceCodeChallenge, | 19 | createPkceCodeChallenge, |
| 19 | createPkceCodeVerifier, | 20 | createPkceCodeVerifier, |
| 20 | exchangeAuthorizationCode, | 21 | exchangeAuthorizationCode, |
| @@ -99,6 +100,11 @@ const tokenResponse = · 78 unmodified lines | |||
| 99 | () => | 100 | () => |
| 100 | json(200, body); | 101 | json(200, body); |
| 101 | 102 | ||
| 103 | /** Assert on the token VALUE, not on `Redacted`'s "<redacted>" rendering — a | ||
| 104 | * `toBe` against the wrapper would pass for any implementation. */ | ||
| 105 | const secretOf = (value: Redacted.Redacted<string> | undefined): string | undefined => | ||
| 106 | value === undefined ? undefined : Redacted.value(value); | ||
| 107 | |||
| 102 | // --------------------------------------------------------------------------- | 108 | // --------------------------------------------------------------------------- |
| 103 | // PKCE | 109 | // PKCE |
| 104 | // --------------------------------------------------------------------------- | 110 | // --------------------------------------------------------------------------- |
| @@ -106,7 +112,7 @@ const tokenResponse = · 1 unmodified line | |||
| 106 | describe("PKCE", () => { | 112 | describe("PKCE", () => { |
| 107 | it("createPkceCodeVerifier returns a base64url string in the RFC 7636 length range", () => { | 113 | it("createPkceCodeVerifier returns a base64url string in the RFC 7636 length range", () => { |
| 108 | for (let i = 0; i < 25; i++) { | 114 | for (let i = 0; i < 25; i++) { |
| 109 | const verifier = createPkceCodeVerifier(); | 115 | const verifier = Redacted.value(createPkceCodeVerifier()); |
| 110 | expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); | 116 | expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); |
| 111 | expect(verifier.length).toBeGreaterThanOrEqual(43); | 117 | expect(verifier.length).toBeGreaterThanOrEqual(43); |
| 112 | expect(verifier.length).toBeLessThanOrEqual(128); | 118 | expect(verifier.length).toBeLessThanOrEqual(128); |
| @@ -117,13 +123,26 @@ describe("PKCE", () => { · 4 unmodified lines | |||
| 117 | const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; | 123 | const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; |
| 118 | const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; | 124 | const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; |
| 119 | expect(await createPkceCodeChallenge(verifier)).toBe(expected); | 125 | expect(await createPkceCodeChallenge(verifier)).toBe(expected); |
| 126 | // A wrapped verifier hashes identically — the challenge is computed from | ||
| 127 | // the value, not from `Redacted`'s "<redacted>" rendering. | ||
| 128 | expect(await createPkceCodeChallenge(Redacted.make(verifier))).toBe(expected); | ||
| 120 | }); | 129 | }); |
| 121 | 130 | ||
| 122 | it("createPkceCodeVerifier produces unique values", () => { | 131 | it("createPkceCodeVerifier produces unique values", () => { |
| 123 | const seen = new Set<string>(); | 132 | const seen = new Set<string>(); |
| 124 | for (let i = 0; i < 50; i++) seen.add(createPkceCodeVerifier()); | 133 | for (let i = 0; i < 50; i++) seen.add(Redacted.value(createPkceCodeVerifier())); |
| 125 | expect(seen.size).toBe(50); | 134 | expect(seen.size).toBe(50); |
| 126 | }); | 135 | }); |
| 136 | |||
| 137 | it("PKCE and state material serializes as <redacted> rather than its value", () => { | ||
| 138 | // These are minted long before they reach a DB column or an authorize URL, | ||
| 139 | // so anything that stringifies them in between (a log line, a span | ||
| 140 | // attribute, a session dump) must not print the grant material. | ||
| 141 | for (const secret of [createPkceCodeVerifier(), createOAuthState()]) { | ||
| 142 | expect(JSON.stringify({ secret })).toBe('{"secret":"<redacted>"}'); | ||
| 143 | expect(String(secret)).not.toContain(Redacted.value(secret)); | ||
| 144 | } | ||
| 145 | }); | ||
| 127 | }); | 146 | }); |
| 128 | 147 | ||
| 129 | // --------------------------------------------------------------------------- | 148 | // --------------------------------------------------------------------------- |
| @@ -259,7 +278,7 @@ describe("exchangeAuthorizationCode", () => { · 129 unmodified lines | |||
| 259 | codeVerifier: "verifier", | 278 | codeVerifier: "verifier", |
| 260 | code: "abc", | 279 | code: "abc", |
| 261 | }); | 280 | }); |
| 262 | expect(result.access_token).toBe("tok"); | 281 | expect(secretOf(result.access_token)).toBe("tok"); |
| 263 | const call = (yield* calls)[0]!; | 282 | const call = (yield* calls)[0]!; |
| 264 | expect(call.method).toBe("POST"); | 283 | expect(call.method).toBe("POST"); |
| 265 | expect(call.headers["content-type"]).toMatch(/^application\/x-www-form-urlencoded/); | 284 | expect(call.headers["content-type"]).toMatch(/^application\/x-www-form-urlencoded/); |
| @@ -398,8 +417,8 @@ describe("exchangeAuthorizationCode", () => { · 132 unmodified lines | |||
| 398 | codeVerifier: "verifier", | 417 | codeVerifier: "verifier", |
| 399 | code: "abc", | 418 | code: "abc", |
| 400 | }); | 419 | }); |
| 401 | expect(result.access_token).toBe("tok"); | 420 | expect(secretOf(result.access_token)).toBe("tok"); |
| 402 | expect(result.refresh_token).toBe("rtok"); | 421 | expect(secretOf(result.refresh_token)).toBe("rtok"); |
| 403 | expect(result.idTokenIdentityLabel).toBe("user-1"); | 422 | expect(result.idTokenIdentityLabel).toBe("user-1"); |
| 404 | }), | 423 | }), |
| 405 | ), | 424 | ), |
| @@ -473,7 +492,7 @@ describe("exchangeAuthorizationCode", () => { · 67 unmodified lines | |||
| 473 | codeVerifier: "verifier", | 492 | codeVerifier: "verifier", |
| 474 | code: "abc", | 493 | code: "abc", |
| 475 | }); | 494 | }); |
| 476 | expect(result.access_token).toBe("tok"); | 495 | expect(secretOf(result.access_token)).toBe("tok"); |
| 477 | expect(result.idTokenIdentityLabel).toBeUndefined(); | 496 | expect(result.idTokenIdentityLabel).toBeUndefined(); |
| 478 | }), | 497 | }), |
| 479 | ), | 498 | ), |
| @@ -501,7 +520,7 @@ describe("exchangeAuthorizationCode", () => { · 21 unmodified lines | |||
| 501 | codeVerifier: "verifier", | 520 | codeVerifier: "verifier", |
| 502 | code: "abc", | 521 | code: "abc", |
| 503 | }); | 522 | }); |
| 504 | expect(result.access_token).toBe("tok"); | 523 | expect(secretOf(result.access_token)).toBe("tok"); |
| 505 | }), | 524 | }), |
| 506 | ), | 525 | ), |
| 507 | ); | 526 | ); |
| @@ -516,8 +535,8 @@ describe("exchangeAuthorizationCode", () => { · 8 unmodified lines | |||
| 516 | codeVerifier: "verifier", | 535 | codeVerifier: "verifier", |
| 517 | code: "abc", | 536 | code: "abc", |
| 518 | }); | 537 | }); |
| 519 | expect(result.access_token).toBe("tok"); | 538 | expect(secretOf(result.access_token)).toBe("tok"); |
| 520 | expect(result.refresh_token).toBe("rtok"); | 539 | expect(secretOf(result.refresh_token)).toBe("rtok"); |
| 521 | expect(result.expires_in).toBe(3600); | 540 | expect(result.expires_in).toBe(3600); |
| 522 | expect(result.idTokenIdentityLabel).toBeUndefined(); | 541 | expect(result.idTokenIdentityLabel).toBeUndefined(); |
| 523 | }), | 542 | }), |
| @@ -577,8 +596,8 @@ describe("exchangeAuthorizationCode", () => { · 53 unmodified lines | |||
| 577 | codeVerifier: "verifier", | 596 | codeVerifier: "verifier", |
| 578 | code: "abc", | 597 | code: "abc", |
| 579 | }); | 598 | }); |
| 580 | expect(result.access_token).toBe("tok"); | 599 | expect(secretOf(result.access_token)).toBe("tok"); |
| 581 | expect(result.refresh_token).toBe("rtok"); | 600 | expect(secretOf(result.refresh_token)).toBe("rtok"); |
| 582 | }), | 601 | }), |
| 583 | ), | 602 | ), |
| 584 | ); | 603 | ); |
| @@ -976,7 +995,7 @@ describe("refreshAccessToken", () => { · 391 unmodified lines | |||
| 976 | clientId: "cid", | 995 | clientId: "cid", |
| 977 | refreshToken: "old", | 996 | refreshToken: "old", |
| 978 | }); | 997 | }); |
| 979 | expect(result.access_token).toBe("tok2"); | 998 | expect(secretOf(result.access_token)).toBe("tok2"); |
| 980 | }), | 999 | }), |
| 981 | ), | 1000 | ), |
| 982 | ); | 1001 | ); |
| @@ -1004,7 +1023,7 @@ describe("refreshAccessToken", () => { · 21 unmodified lines | |||
| 1004 | clientId: "cid", | 1023 | clientId: "cid", |
| 1005 | refreshToken: "old", | 1024 | refreshToken: "old", |
| 1006 | }); | 1025 | }); |
| 1007 | expect(result.access_token).toBe("tok2"); | 1026 | expect(secretOf(result.access_token)).toBe("tok2"); |
| 1008 | }), | 1027 | }), |
| 1009 | ), | 1028 | ), |
| 1010 | ); | 1029 | ); |
| @@ -1017,13 +1036,208 @@ describe("refreshAccessToken", () => { · 6 unmodified lines | |||
| 1017 | clientId: "cid", | 1036 | clientId: "cid", |
| 1018 | refreshToken: "old", | 1037 | refreshToken: "old", |
| 1019 | }); | 1038 | }); |
| 1020 | expect(result.access_token).toBe("tok2"); | 1039 | expect(secretOf(result.access_token)).toBe("tok2"); |
| 1021 | expect(result.expires_in).toBe(3600); | 1040 | expect(result.expires_in).toBe(3600); |
| 1022 | }), | 1041 | }), |
| 1023 | ), | 1042 | ), |
| 1024 | ); | 1043 | ); |
| 1025 | }); | 1044 | }); |
| 1026 | 1045 | ||
| 1046 | // --------------------------------------------------------------------------- | ||
| 1047 | // Token material is Redacted end to end. | ||
| 1048 | // | ||
| 1049 | // Two halves, both required: the WIRE half (the secret still reaches the token | ||
| 1050 | // endpoint byte for byte, so the wrapping is not merely decorative) and the | ||
| 1051 | // LEAK half (a failed exchange's error and log output quotes neither the tokens | ||
| 1052 | // the caller sent nor the ones the AS echoed back). Either half alone passes | ||
| 1053 | // trivially for a broken implementation. | ||
| 1054 | // --------------------------------------------------------------------------- | ||
| 1055 | |||
| 1056 | // Synthetic — not shaped like any real provider's token. | ||
| 1057 | const SENT_REFRESH = "synthetic-sent-refresh-value"; | ||
| 1058 | const SENT_SECRET = "synthetic-sent-client-secret"; | ||
| 1059 | const ECHOED_ACCESS = "synthetic-echoed-access-value"; | ||
| 1060 | const ECHOED_REFRESH = "synthetic-echoed-refresh-value"; | ||
| 1061 | |||
| 1062 | describe("OAuth token material as Redacted", () => { | ||
| 1063 | it.effect("a wrapped refresh token and client secret reach the token endpoint intact", () => | ||
| 1064 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => | ||
| 1065 | Effect.gen(function* () { | ||
| 1066 | yield* refreshAccessToken({ | ||
| 1067 | tokenUrl, | ||
| 1068 | clientId: "cid", | ||
| 1069 | clientSecret: Redacted.make(SENT_SECRET), | ||
| 1070 | refreshToken: Redacted.make(SENT_REFRESH), | ||
| 1071 | }); | ||
| 1072 | const body = (yield* calls)[0]!.body; | ||
| 1073 | expect(body.get("refresh_token")).toBe(SENT_REFRESH); | ||
| 1074 | expect(body.get("client_secret")).toBe(SENT_SECRET); | ||
| 1075 | }), | ||
| 1076 | ), | ||
| 1077 | ); | ||
| 1078 | |||
| 1079 | it.effect("a wrapped code and PKCE verifier reach the token endpoint intact", () => | ||
| 1080 | withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => | ||
| 1081 | Effect.gen(function* () { | ||
| 1082 | yield* exchangeAuthorizationCode({ | ||
| 1083 | tokenUrl, | ||
| 1084 | clientId: "cid", | ||
| 1085 | clientSecret: Redacted.make(SENT_SECRET), | ||
| 1086 | redirectUrl: "https://app.example.com/cb", | ||
| 1087 | codeVerifier: Redacted.make("synthetic-verifier-value"), | ||
| 1088 | code: Redacted.make("synthetic-code-value"), | ||
| 1089 | }); | ||
| 1090 | const body = (yield* calls)[0]!.body; | ||
| 1091 | expect(body.get("code")).toBe("synthetic-code-value"); | ||
| 1092 | expect(body.get("code_verifier")).toBe("synthetic-verifier-value"); | ||
| 1093 | expect(body.get("client_secret")).toBe(SENT_SECRET); | ||
| 1094 | }), | ||
| 1095 | ), | ||
| 1096 | ); | ||
| 1097 | |||
| 1098 | it.effect("a successful exchange returns tokens that serialize as <redacted>", () => | ||
| 1099 | // Distinct synthetic values rather than the shared `validCodeBody` fixture: | ||
| 1100 | // its "tok" is a substring of the `token_type` key, so a not-to-contain | ||
| 1101 | // assertion against it could never be trusted. | ||
| 1102 | withTokenEndpoint( | ||
| 1103 | tokenResponse({ | ||
| 1104 | access_token: ECHOED_ACCESS, | ||
| 1105 | token_type: "Bearer", | ||
| 1106 | refresh_token: ECHOED_REFRESH, | ||
| 1107 | expires_in: 3600, | ||
| 1108 | }), | ||
| 1109 | ({ tokenUrl }) => | ||
| 1110 | Effect.gen(function* () { | ||
| 1111 | const result = yield* exchangeAuthorizationCode({ | ||
| 1112 | tokenUrl, | ||
| 1113 | clientId: "cid", | ||
| 1114 | redirectUrl: "https://app.example.com/cb", | ||
| 1115 | codeVerifier: "v", | ||
| 1116 | code: "c", | ||
| 1117 | }); | ||
| 1118 | // The values survive an explicit unwrap … | ||
| 1119 | expect(secretOf(result.access_token)).toBe(ECHOED_ACCESS); | ||
| 1120 | expect(secretOf(result.refresh_token)).toBe(ECHOED_REFRESH); | ||
| 1121 | // … but a caller that forgets one serializes the marker, not the token. | ||
| 1122 | const serialized = JSON.stringify(result); | ||
| 1123 | expect(serialized).toContain('"access_token":"<redacted>"'); | ||
| 1124 | expect(serialized).toContain('"refresh_token":"<redacted>"'); | ||
| 1125 | expect(serialized).not.toContain(ECHOED_ACCESS); | ||
| 1126 | expect(serialized).not.toContain(ECHOED_REFRESH); | ||
| 1127 | }), | ||
| 1128 | ), | ||
| 1129 | ); | ||
| 1130 | |||
| 1131 | it.effect("an AS that returns no refresh_token yields undefined, not a wrapped empty", () => | ||
| 1132 | // Presence must survive the wrapping: `Redacted.make("")` is truthy, so a | ||
| 1133 | // downstream truthiness test on the field would report a refresh token that | ||
| 1134 | // does not exist and mark the connection refreshable when it is not. | ||
| 1135 | withTokenEndpoint( | ||
| 1136 | tokenResponse({ access_token: "tok", token_type: "Bearer", expires_in: 3600 }), | ||
| 1137 | ({ tokenUrl }) => | ||
| 1138 | Effect.gen(function* () { | ||
| 1139 | const result = yield* exchangeAuthorizationCode({ | ||
| 1140 | tokenUrl, | ||
| 1141 | clientId: "cid", | ||
| 1142 | redirectUrl: "https://app.example.com/cb", | ||
| 1143 | codeVerifier: "v", | ||
| 1144 | code: "c", | ||
| 1145 | }); | ||
| 1146 | expect(result.refresh_token).toBeUndefined(); | ||
| 1147 | expect(Redacted.isRedacted(result.access_token)).toBe(true); | ||
| 1148 | }), | ||
| 1149 | ), | ||
| 1150 | ); | ||
| 1151 | |||
| 1152 | it.effect("a failed exchange leaks neither the sent nor the echoed token material", () => | ||
| 1153 | // The token endpoint echoes the grant it rejected, and the failure summary | ||
| 1154 | // quotes that body. This is the silent-failure shape the whole change | ||
| 1155 | // exists for: the error reaches a user-visible message AND the log, so | ||
| 1156 | // assert against both renderings of the failure. | ||
| 1157 | withTokenEndpoint( | ||
| 1158 | () => | ||
| 1159 | json(400, { | ||
| 1160 | error: "invalid_grant", | ||
| 1161 | error_description: "Refresh token is no longer valid", | ||
| 1162 | refresh_token: ECHOED_REFRESH, | ||
| 1163 | access_token: ECHOED_ACCESS, | ||
| 1164 | }), | ||
| 1165 | ({ tokenUrl }) => | ||
| 1166 | Effect.gen(function* () { | ||
| 1167 | // `Effect.flip` keeps the failure typed as `OAuth2Error` — the tagged | ||
| 1168 | // error carries `message` and `error` as declared fields. | ||
| 1169 | const error = yield* Effect.flip( | ||
| 1170 | refreshAccessToken({ | ||
| 1171 | tokenUrl, | ||
| 1172 | clientId: "cid", | ||
| 1173 | clientSecret: Redacted.make(SENT_SECRET), | ||
| 1174 | refreshToken: Redacted.make(SENT_REFRESH), | ||
| 1175 | }), | ||
| 1176 | ); | ||
| 1177 | // The diagnostic that makes the failure worth surfacing survives. | ||
| 1178 | expect(error.error).toBe("invalid_grant"); | ||
| 1179 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`; the leak test asserts on its rendering | ||
| 1180 | expect(error.message).toContain("Refresh token is no longer valid"); | ||
| 1181 | |||
| 1182 | // The rendering a log actually emits: the error goes through the | ||
| 1183 | // logger's own cause serialization, not a hand-rolled stringify. | ||
| 1184 | const logged = yield* Effect.gen(function* () { | ||
| 1185 | const lines = yield* Ref.make<readonly string[]>([]); | ||
| 1186 | yield* Effect.logError("OAuth token refresh failed", error).pipe( | ||
| 1187 | Effect.provide( | ||
| 1188 | Logger.layer([ | ||
| 1189 | Logger.make((options) => | ||
| 1190 | Effect.runSync( | ||
| 1191 | Ref.update(lines, (all) => [ | ||
| 1192 | ...all, | ||
| 1193 | `${options.message} ${String(options.cause)}`, | ||
| 1194 | ]), | ||
| 1195 | ), | ||
| 1196 | ), | ||
| 1197 | ]), | ||
| 1198 | ), | ||
| 1199 | ); | ||
| 1200 | return (yield* Ref.get(lines)).join("\n"); | ||
| 1201 | }); | ||
| 1202 | |||
| 1203 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message`; the leak test asserts on its rendering | ||
| 1204 | for (const rendering of [error.message, JSON.stringify(error), logged]) { | ||
| 1205 | for (const secret of [ | ||
| 1206 | SENT_REFRESH, | ||
| 1207 | SENT_SECRET, | ||
| 1208 | ECHOED_ACCESS, | ||
| 1209 | ECHOED_REFRESH, | ||
| 1210 | ] as const) { | ||
| 1211 | expect(rendering).not.toContain(secret); | ||
| 1212 | } | ||
| 1213 | } | ||
| 1214 | }), | ||
| 1215 | ), | ||
| 1216 | ); | ||
| 1217 | |||
| 1218 | it.effect("redacting the cause keeps a transport failure's own diagnostics", () => | ||
| 1219 | // A native fetch failure has NO enumerable own property — its name, | ||
| 1220 | // message, and stack are all non-enumerable — so projecting the cause | ||
| 1221 | // through a plain object walk silently reduces it to `{}`. Redaction must | ||
| 1222 | // not cost the only diagnostic a connection-refused failure has. | ||
| 1223 | Effect.gen(function* () { | ||
| 1224 | const error = yield* Effect.flip( | ||
| 1225 | exchangeAuthorizationCode({ | ||
| 1226 | tokenUrl: "http://127.0.0.1:1/token", | ||
| 1227 | clientId: "cid", | ||
| 1228 | redirectUrl: "https://cb", | ||
| 1229 | codeVerifier: "v", | ||
| 1230 | code: "c", | ||
| 1231 | timeoutMs: 100, | ||
| 1232 | }), | ||
| 1233 | ); | ||
| 1234 | expect(Object.keys(error.cause as object)).toEqual( | ||
| 1235 | expect.arrayContaining(["name", "message"]), | ||
| 1236 | ); | ||
| 1237 | }), | ||
| 1238 | ); | ||
| 1239 | }); | ||
| 1240 | |||
| 1027 | describe("shouldRefreshToken", () => { | 1241 | describe("shouldRefreshToken", () => { |
| 1028 | it("never refreshes when expiresAt is null", () => { | 1242 | it("never refreshes when expiresAt is null", () => { |
| 1029 | expect(shouldRefreshToken({ expiresAt: null })).toBe(false); | 1243 | expect(shouldRefreshToken({ expiresAt: null })).toBe(false); |
| @@ -16,7 +16,7 @@ · 15 unmodified lines | |||
| 16 | // construction keeps the call sync and lets callers opt out of PAR | 16 | // construction keeps the call sync and lets callers opt out of PAR |
| 17 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | ||
| 19 | import { Data, Effect, Option, Predicate, Schema } from "effect"; | 19 | import { Data, Effect, Option, Predicate, Redacted, Schema } from "effect"; |
| 20 | import * as oauth from "oauth4webapi"; | 20 | import * as oauth from "oauth4webapi"; |
| 21 | 21 | ||
| 22 | import { endpointForTelemetry } from "./telemetry-endpoint"; | 22 | import { endpointForTelemetry } from "./telemetry-endpoint"; |
| @@ -41,10 +41,25 @@ export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{ · 18 unmodified lines | |||
| 41 | // Token response shape (RFC 6749 §5.1) | 41 | // Token response shape (RFC 6749 §5.1) |
| 42 | // --------------------------------------------------------------------------- | 42 | // --------------------------------------------------------------------------- |
| 43 | 43 | ||
| 44 | /** Token material a caller hands one of these helpers. Widened to accept a bare | ||
| 45 | * string as well as `Redacted` so a value that never left the process (a code | ||
| 46 | * straight off the callback query string, a pasted secret) does not have to be | ||
| 47 | * wrapped first. Outputs are always `Redacted` — the guarantee lives there. */ | ||
| 48 | export type OAuth2SecretInput = string | Redacted.Redacted<string>; | ||
| 49 | |||
| 50 | /** Unwrap at an allowlisted boundary: `oauth4webapi` takes bare strings and puts | ||
| 51 | * them on the wire. Every call site is a form field or an HTTP header. */ | ||
| 52 | const secretToSend = (value: OAuth2SecretInput): string => | ||
| 53 | Redacted.isRedacted(value) ? Redacted.value(value) : value; | ||
| 54 | |||
| 44 | export type OAuth2TokenResponse = { | 55 | export type OAuth2TokenResponse = { |
| 45 | readonly access_token: string; | 56 | /** Wrapped at construction (`tokenResponseFrom`) so the minted token cannot |
| 57 | * reach a log, a span attribute, or an error payload without an explicit | ||
| 58 | * `Redacted.value`. Unwrap only at a persistence or wire line — a missed | ||
| 59 | * unwrap does not throw, it writes the literal "<redacted>". */ | ||
| 60 | readonly access_token: Redacted.Redacted<string>; | ||
| 46 | readonly token_type?: string; | 61 | readonly token_type?: string; |
| 47 | readonly refresh_token?: string; | 62 | readonly refresh_token?: Redacted.Redacted<string>; |
| 48 | readonly expires_in?: number; | 63 | readonly expires_in?: number; |
| 49 | readonly scope?: string; | 64 | readonly scope?: string; |
| 50 | readonly idTokenIdentityLabel?: string; | 65 | readonly idTokenIdentityLabel?: string; |
| @@ -105,14 +120,22 @@ export const assertSupportedOAuthEndpointUrl = ( · 54 unmodified lines | |||
| 105 | // PKCE (RFC 7636) — straight delegation to `oauth4webapi` | 120 | // PKCE (RFC 7636) — straight delegation to `oauth4webapi` |
| 106 | // --------------------------------------------------------------------------- | 121 | // --------------------------------------------------------------------------- |
| 107 | 122 | ||
| 108 | export const createPkceCodeVerifier = (): string => oauth.generateRandomCodeVerifier(); | 123 | /** The PKCE verifier is grant material: whoever holds it plus the authorization |
| 124 | * code can redeem the token. Wrapped for its whole in-memory life; the only | ||
| 125 | * unwraps are the challenge computation below and the session-row write. */ | ||
| 126 | export const createPkceCodeVerifier = (): Redacted.Redacted<string> => | ||
| 127 | Redacted.make(oauth.generateRandomCodeVerifier()); | ||
| 109 | 128 | ||
| 110 | export const createPkceCodeChallenge = (verifier: string): Promise<string> => | 129 | /** The challenge is a public S256 hash of the verifier (it travels in the |
| 111 | oauth.calculatePKCECodeChallenge(verifier); | 130 | * authorize URL), so it comes back bare. */ |
| 131 | export const createPkceCodeChallenge = (verifier: OAuth2SecretInput): Promise<string> => | ||
| 132 | oauth.calculatePKCECodeChallenge(secretToSend(verifier)); | ||
| 112 | 133 | ||
| 113 | /** RFC 6749 `state` — an unguessable correlation token minted by `oauth.start` | 134 | /** RFC 6749 `state` — an unguessable correlation token minted by `oauth.start` |
| 114 | * and redeemed by `oauth.complete`. */ | 135 | * and redeemed by `oauth.complete`. Wrapped like the verifier: it is the |
| 115 | export const createOAuthState = (): string => oauth.generateRandomState(); | 136 | * session's bearer key, and it is persisted and echoed through the provider. */ |
| 137 | export const createOAuthState = (): Redacted.Redacted<string> => | ||
| 138 | Redacted.make(oauth.generateRandomState()); | ||
| 116 | 139 | ||
| 117 | // --------------------------------------------------------------------------- | 140 | // --------------------------------------------------------------------------- |
| 118 | // Authorization URL builder | 141 | // Authorization URL builder |
| @@ -316,6 +339,62 @@ const redactTokenEndpointBody = (body: string): string => · 197 unmodified lines | |||
| 316 | ) | 339 | ) |
| 317 | .replaceAll(new RegExp(`((?:${CREDENTIAL_BODY_KEYS}|code)=)[^&\\s]*`, "gi"), "$1[redacted]"); | 340 | .replaceAll(new RegExp(`((?:${CREDENTIAL_BODY_KEYS}|code)=)[^&\\s]*`, "gi"), "$1[redacted]"); |
| 318 | 341 | ||
| 342 | const CREDENTIAL_KEY_PATTERN = new RegExp(`^(?:${CREDENTIAL_BODY_KEYS})$`, "i"); | ||
| 343 | |||
| 344 | /** The cause an `OAuth2Error` carries is the raw failure oauth4webapi threw, and | ||
| 345 | * for an RFC 6749 §5.2 rejection that object holds the endpoint's PARSED error | ||
| 346 | * body — which echoes the grant it rejected. The `message` is summarized | ||
| 347 | * through `redactTokenEndpointBody`, but the cause is not, and anything that | ||
| 348 | * serializes the failure (a log line, an error payload, `JSON.stringify` of the | ||
| 349 | * Effect cause) republishes those credentials verbatim. | ||
| 350 | * | ||
| 351 | * So the cause is projected into a plain object with every credential-named | ||
| 352 | * leaf replaced, keeping the diagnostics (`error`, `error_description`, | ||
| 353 | * `status`, `name`) that make it worth attaching. `Response` instances pass | ||
| 354 | * through untouched: their body is not enumerable, and the summary already | ||
| 355 | * quoted a redacted preview of it. */ | ||
| 356 | const redactErrorCause = ( | ||
| 357 | cause: unknown, | ||
| 358 | /** True when `cause` is the value of an `error` key, where a `code` entry is | ||
| 359 | * the provider's error LABEL (`{"error":{"code":"invalid_client_id"}}`) and | ||
| 360 | * not an authorization code. Same exemption `redactTokenEndpointBody` makes | ||
| 361 | * for the text form, spelled structurally. */ | ||
| 362 | insideErrorObject = false, | ||
| 363 | seen: ReadonlySet<object> = new Set(), | ||
| 364 | ): unknown => { | ||
| 365 | if (cause instanceof Response) return cause; | ||
| 366 | if (seen.has(cause as object)) return "[circular]"; | ||
| 367 | if (Array.isArray(cause)) { | ||
| 368 | const next = new Set([...seen, cause]); | ||
| 369 | return cause.map((entry) => redactErrorCause(entry, false, next)); | ||
| 370 | } | ||
| 371 | if (!Predicate.isObject(cause)) return cause; | ||
| 372 | const next = new Set([...seen, cause]); | ||
| 373 | const redacted = Object.fromEntries( | ||
| 374 | Object.entries(cause).map(([key, value]) => [ | ||
| 375 | key, | ||
| 376 | CREDENTIAL_KEY_PATTERN.test(key) || (key === "code" && !insideErrorObject) | ||
| 377 | ? "[redacted]" | ||
| 378 | : redactErrorCause(value, key === "error", next), | ||
| 379 | ]), | ||
| 380 | ); | ||
| 381 | // oxlint-disable-next-line executor/no-instanceof-error -- boundary: `cause` is the untyped value oauth4webapi threw; narrowing it is how the diagnostics below are recovered | ||
| 382 | if (!(cause instanceof Error)) return redacted; | ||
| 383 | // `name`, `message`, and `stack` are non-enumerable on an Error, so the | ||
| 384 | // projection above drops them — and for a transport failure they are the ONLY | ||
| 385 | // diagnostic (a native fetch TypeError has no enumerable own property at | ||
| 386 | // all). Carry them explicitly, with the message scrubbed the same way the | ||
| 387 | // HTTP summary scrubs a body: a library that quotes a rejected field in its | ||
| 388 | // message must not smuggle the value back in through the cause. | ||
| 389 | return { | ||
| 390 | ...redacted, | ||
| 391 | name: cause.name, | ||
| 392 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: narrowed to Error above; the message is scrubbed, not interpreted | ||
| 393 | message: redactTokenEndpointBody(cause.message), | ||
| 394 | ...(cause.stack === undefined ? {} : { stack: cause.stack }), | ||
| 395 | }; | ||
| 396 | }; | ||
| 397 | |||
| 319 | const tokenEndpointHttpSummary = async (response: Response): Promise<string> => { | 398 | const tokenEndpointHttpSummary = async (response: Response): Promise<string> => { |
| 320 | const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; | 399 | const status = `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`; |
| 321 | const contentType = response.headers.get("content-type"); | 400 | const contentType = response.headers.get("content-type"); |
| @@ -359,7 +438,7 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 37 unmodified lines | |||
| 359 | return new OAuth2Error({ | 438 | return new OAuth2Error({ |
| 360 | message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, | 439 | message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`, |
| 361 | error: code, | 440 | error: code, |
| 362 | cause, | 441 | cause: redactErrorCause(cause), |
| 363 | }); | 442 | }); |
| 364 | } | 443 | } |
| 365 | return new OAuth2Error({ | 444 | return new OAuth2Error({ |
| @@ -379,7 +458,7 @@ const toOAuth2ErrorWithHttpSummary = (cause: unknown): Effect.Effect<OAuth2Error · 13 unmodified lines | |||
| 379 | new OAuth2Error({ | 458 | new OAuth2Error({ |
| 380 | message: `${base.message} (${summary})`, | 459 | message: `${base.message} (${summary})`, |
| 381 | error: base.error, | 460 | error: base.error, |
| 382 | cause, | 461 | cause: base.cause, |
| 383 | }), | 462 | }), |
| 384 | ), | 463 | ), |
| 385 | ); | 464 | ); |
| @@ -467,19 +546,22 @@ const oauth4webapiRequestOptions = ( · 81 unmodified lines | |||
| 467 | // public rather than failing on the malformed row. The `method` only chooses | 546 | // public rather than failing on the malformed row. The `method` only chooses |
| 468 | // HOW a present secret is sent (post vs basic). | 547 | // HOW a present secret is sent (post vs basic). |
| 469 | const pickClientAuth = ( | 548 | const pickClientAuth = ( |
| 470 | clientSecret: string | null | undefined, | 549 | clientSecret: OAuth2SecretInput | null | undefined, |
| 471 | method: ClientAuthMethod, | 550 | method: ClientAuthMethod, |
| 472 | ): oauth.ClientAuth => { | 551 | ): oauth.ClientAuth => { |
| 473 | if (clientSecret == null) return oauth.None(); | 552 | if (clientSecret == null) return oauth.None(); |
| 474 | return method === "basic" | 553 | // Boundary: oauth4webapi puts the secret in the form body / Basic header. |
| 475 | ? oauth.ClientSecretBasic(clientSecret) | 554 | const secret = secretToSend(clientSecret); |
| 476 | : oauth.ClientSecretPost(clientSecret); | 555 | return method === "basic" ? oauth.ClientSecretBasic(secret) : oauth.ClientSecretPost(secret); |
| 477 | }; | 556 | }; |
| 478 | 557 | ||
| 479 | const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({ | 558 | const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({ |
| 480 | access_token: r.access_token, | 559 | access_token: Redacted.make(r.access_token), |
| 481 | token_type: r.token_type, | 560 | token_type: r.token_type, |
| 482 | refresh_token: r.refresh_token, | 561 | // Presence is `undefined`, never falsiness: `Redacted.make("")` is truthy, so |
| 562 | // every downstream test asks whether the field is present. oauth4webapi has | ||
| 563 | // already rejected an empty `refresh_token` by this point. | ||
| 564 | refresh_token: r.refresh_token === undefined ? undefined : Redacted.make(r.refresh_token), | ||
| 483 | expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, | 565 | expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, |
| 484 | scope: r.scope, | 566 | scope: r.scope, |
| 485 | }); | 567 | }); |
| @@ -578,10 +660,10 @@ export type ExchangeAuthorizationCodeInput = { · 92 unmodified lines | |||
| 578 | readonly tokenUrl: string; | 660 | readonly tokenUrl: string; |
| 579 | readonly issuerUrl?: string | null; | 661 | readonly issuerUrl?: string | null; |
| 580 | readonly clientId: string; | 662 | readonly clientId: string; |
| 581 | readonly clientSecret?: string | null; | 663 | readonly clientSecret?: OAuth2SecretInput | null; |
| 582 | readonly redirectUrl: string; | 664 | readonly redirectUrl: string; |
| 583 | readonly codeVerifier: string; | 665 | readonly codeVerifier: OAuth2SecretInput; |
| 584 | readonly code: string; | 666 | readonly code: OAuth2SecretInput; |
| 585 | readonly clientAuth?: ClientAuthMethod; | 667 | readonly clientAuth?: ClientAuthMethod; |
| 586 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; | 668 | readonly idTokenSigningAlgValuesSupported?: readonly string[]; |
| 587 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on | 669 | /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on |
| @@ -612,10 +694,11 @@ export const exchangeAuthorizationCode = ( · 24 unmodified lines | |||
| 612 | // takes the `code` directly (the UI already validated `state` by | 694 | // takes the `code` directly (the UI already validated `state` by |
| 613 | // looking up the session), so skip the library's state-validation | 695 | // looking up the session), so skip the library's state-validation |
| 614 | // rail and go through the generic grant request instead. | 696 | // rail and go through the generic grant request instead. |
| 697 | // Boundary: both go out as RFC 6749 form fields. | ||
| 615 | const params = new URLSearchParams({ | 698 | const params = new URLSearchParams({ |
| 616 | code: input.code, | 699 | code: secretToSend(input.code), |
| 617 | redirect_uri: input.redirectUrl, | 700 | redirect_uri: input.redirectUrl, |
| 618 | code_verifier: input.codeVerifier, | 701 | code_verifier: secretToSend(input.codeVerifier), |
| 619 | }); | 702 | }); |
| 620 | if (input.resource) { | 703 | if (input.resource) { |
| 621 | params.set("resource", input.resource); | 704 | params.set("resource", input.resource); |
| @@ -647,7 +730,7 @@ export type ExchangeClientCredentialsInput = { · 25 unmodified lines | |||
| 647 | readonly clientId: string; | 730 | readonly clientId: string; |
| 648 | /** Null for a client the AS registered without a secret; the grant then goes | 731 | /** Null for a client the AS registered without a secret; the grant then goes |
| 649 | * out unauthenticated and the AS decides. Never `""` — see `OAuthClient`. */ | 732 | * out unauthenticated and the AS decides. Never `""` — see `OAuthClient`. */ |
| 650 | readonly clientSecret: string | null; | 733 | readonly clientSecret: OAuth2SecretInput | null; |
| 651 | readonly scopes?: readonly string[]; | 734 | readonly scopes?: readonly string[]; |
| 652 | readonly scopeSeparator?: string; | 735 | readonly scopeSeparator?: string; |
| 653 | readonly clientAuth?: ClientAuthMethod; | 736 | readonly clientAuth?: ClientAuthMethod; |
| @@ -703,8 +786,8 @@ export type RefreshAccessTokenInput = { · 49 unmodified lines | |||
| 703 | readonly tokenUrl: string; | 786 | readonly tokenUrl: string; |
| 704 | readonly issuerUrl?: string | null; | 787 | readonly issuerUrl?: string | null; |
| 705 | readonly clientId: string; | 788 | readonly clientId: string; |
| 706 | readonly clientSecret?: string | null; | 789 | readonly clientSecret?: OAuth2SecretInput | null; |
| 707 | readonly refreshToken: string; | 790 | readonly refreshToken: OAuth2SecretInput; |
| 708 | readonly scopes?: readonly string[]; | 791 | readonly scopes?: readonly string[]; |
| 709 | readonly scopeSeparator?: string; | 792 | readonly scopeSeparator?: string; |
| 710 | readonly clientAuth?: ClientAuthMethod; | 793 | readonly clientAuth?: ClientAuthMethod; |
| @@ -745,7 +828,8 @@ export const refreshAccessToken = ( · 34 unmodified lines | |||
| 745 | as, | 828 | as, |
| 746 | client, | 829 | client, |
| 747 | clientAuth, | 830 | clientAuth, |
| 748 | input.refreshToken, | 831 | // Boundary: goes out as the `refresh_token` form field. |
| 832 | secretToSend(input.refreshToken), | ||
| 749 | { | 833 | { |
| 750 | ...oauth4webapiRequestOptions( | 834 | ...oauth4webapiRequestOptions( |
| 751 | input.tokenUrl, | 835 | input.tokenUrl, |
| @@ -201,8 +201,12 @@ const recordedOAuthScope = ( · 200 unmodified lines | |||
| 201 | if (token.scope == null) return requestedScopes.join(" ") || null; | 201 | if (token.scope == null) return requestedScopes.join(" ") || null; |
| 202 | 202 | ||
| 203 | const granted = token.scope.split(/\s+/).filter(Boolean); | 203 | const granted = token.scope.split(/\s+/).filter(Boolean); |
| 204 | // Presence, not truthiness: the refresh token is a `Redacted`, and every | ||
| 205 | // `Redacted` is truthy. | ||
| 204 | const coveredByRefreshToken = | 206 | const coveredByRefreshToken = |
| 205 | token.refresh_token && requestedScopes.includes("offline_access") ? ["offline_access"] : []; | 207 | token.refresh_token !== undefined && requestedScopes.includes("offline_access") |
| 208 | ? ["offline_access"] | ||
| 209 | : []; | ||
| 206 | const recorded = dedupeScopes([...granted, ...coveredByRefreshToken]); | 210 | const recorded = dedupeScopes([...granted, ...coveredByRefreshToken]); |
| 207 | return recorded.join(" ") || null; | 211 | return recorded.join(" ") || null; |
| 208 | }; | 212 | }; |
| @@ -388,9 +392,11 @@ interface LoadedOAuthClient { · 179 unmodified lines | |||
| 388 | readonly tokenUrl: string; | 392 | readonly tokenUrl: string; |
| 389 | readonly grant: OAuthGrant; | 393 | readonly grant: OAuthGrant; |
| 390 | readonly clientId: string; | 394 | readonly clientId: string; |
| 391 | /** Resolved literal secret (read from the provider via the stored item id), | 395 | /** Resolved secret (read from the provider via the stored item id), or null |
| 392 | * or null for a public / PKCE client that stored none. */ | 396 | * for a public / PKCE client that stored none. Presence stays `null` — an |
| 393 | readonly clientSecret: string | null; | 397 | * emptiness test on the wrapper would read a public client as confidential, |
| 398 | * since `Redacted.make("")` is truthy. */ | ||
| 399 | readonly clientSecret: Redacted.Redacted<string> | null; | ||
| 394 | readonly resource: string | null; | 400 | readonly resource: string | null; |
| 395 | } | 401 | } |
| 396 | 402 | ||
| @@ -603,8 +609,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 206 unmodified lines | |||
| 603 | // secret is empty (the token request would then send `client_secret=`), | 609 | // secret is empty (the token request would then send `client_secret=`), |
| 604 | // so reject it and make the caller state which it meant. Boundaries that | 610 | // so reject it and make the caller state which it meant. Boundaries that |
| 605 | // can only produce "" (HTTP payloads, form fields) normalize through | 611 | // can only produce "" (HTTP payloads, form fields) normalize through |
| 606 | // `oauthClientSecretFromInput` before calling. | 612 | // `oauthClientSecretFromInput` before calling. Tested on the unwrapped |
| 607 | if (input.clientSecret === "") { | 613 | // value: a wrapped "" is just as empty and just as wrong. |
| 614 | if ( | ||
| 615 | input.clientSecret != null && | ||
| 616 | (Redacted.isRedacted(input.clientSecret) | ||
| 617 | ? Redacted.value(input.clientSecret) | ||
| 618 | : input.clientSecret) === "" | ||
| 619 | ) { | ||
| 608 | return yield* new StorageError({ | 620 | return yield* new StorageError({ |
| 609 | message: | 621 | message: |
| 610 | "Invalid OAuth client secret: pass null for a public/PKCE client, or a non-empty secret for a confidential one.", | 622 | "Invalid OAuth client secret: pass null for a public/PKCE client, or a non-empty secret for a confidential one.", |
| @@ -1025,21 +1037,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 414 unmodified lines | |||
| 1025 | // goes out unauthenticated and the AS rejects it, which is the visible | 1037 | // goes out unauthenticated and the AS rejects it, which is the visible |
| 1026 | // failure a re-registration prompt hangs off. | 1038 | // failure a re-registration prompt hangs off. |
| 1027 | return Effect.gen(function* () { | 1039 | return Effect.gen(function* () { |
| 1028 | let clientSecret: string | null = null; | 1040 | let clientSecret: Redacted.Redacted<string> | null = null; |
| 1029 | if (row.client_secret_item_id != null) { | 1041 | if (row.client_secret_item_id != null) { |
| 1030 | const provider = deps.defaultWritableProvider(); | 1042 | const provider = deps.defaultWritableProvider(); |
| 1031 | if (provider) { | 1043 | if (provider) { |
| 1032 | // Boundary: the OAuth 2.1 helpers take a bare `client_secret` | 1044 | // The secret stays wrapped from here to the token endpoint; the |
| 1033 | // (it is posted to the token endpoint). Unwrap BEFORE | 1045 | // helpers unwrap it at the oauth4webapi call. Presence is |
| 1034 | // presence-normalizing — `Redacted.make("")` is truthy, so an | 1046 | // normalized against the UNWRAPPED value — `Redacted.make("")` |
| 1035 | // emptiness test on the wrapper would read a public client as | 1047 | // is truthy, so testing the wrapper would read a public client |
| 1036 | // confidential. | 1048 | // as confidential. |
| 1037 | const stored = yield* provider.get( | 1049 | const stored = yield* provider.get( |
| 1038 | ProviderItemId.make(String(row.client_secret_item_id)), | 1050 | ProviderItemId.make(String(row.client_secret_item_id)), |
| 1039 | ); | 1051 | ); |
| 1040 | clientSecret = oauthClientSecretFromInput( | 1052 | clientSecret = |
| 1041 | stored === null ? null : Redacted.value(stored), | 1053 | stored !== null && oauthClientSecretFromInput(Redacted.value(stored)) !== null |
| 1042 | ); | 1054 | ? stored |
| 1055 | : null; | ||
| 1043 | } | 1056 | } |
| 1044 | } | 1057 | } |
| 1045 | return { | 1058 | return { |
| @@ -1174,9 +1187,16 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 128 unmodified lines | |||
| 1174 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); | 1187 | : yield* filterAuthorizationCodeScopes(client, requestedScopes); |
| 1175 | 1188 | ||
| 1176 | // authorization_code: persist a session + build the authorize URL. | 1189 | // authorization_code: persist a session + build the authorize URL. |
| 1190 | // The verifier stays wrapped across the challenge computation and down to | ||
| 1191 | // the session write below; the challenge itself is a public S256 hash. | ||
| 1177 | const verifier = createPkceCodeVerifier(); | 1192 | const verifier = createPkceCodeVerifier(); |
| 1178 | const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier)); | 1193 | const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier)); |
| 1179 | const state = OAuthState.make(createOAuthState()); | 1194 | // Boundary: `OAuthState` is a correlation KEY, not just grant material — |
| 1195 | // it is matched by equality in `oauth_session`, echoed to the provider in | ||
| 1196 | // the authorize URL, and returned to the caller so `complete` can quote | ||
| 1197 | // it. It cannot stay wrapped, so the unwrap happens here at the branded-id | ||
| 1198 | // constructor rather than being spread across those call sites. | ||
| 1199 | const state = OAuthState.make(Redacted.value(createOAuthState())); | ||
| 1180 | const providerState = encodeOAuthCallbackState({ | 1200 | const providerState = encodeOAuthCallbackState({ |
| 1181 | state: String(state), | 1201 | state: String(state), |
| 1182 | orgSlug: deps.callbackStateOrgSlug, | 1202 | orgSlug: deps.callbackStateOrgSlug, |
| @@ -1195,7 +1215,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 12 unmodified lines | |||
| 1195 | name: String(input.name), | 1215 | name: String(input.name), |
| 1196 | template: String(input.template), | 1216 | template: String(input.template), |
| 1197 | redirect_url: flowRedirectUri, | 1217 | redirect_url: flowRedirectUri, |
| 1198 | pkce_verifier: verifier, | 1218 | // Boundary: the DB persistence line. `Redacted`'s toJSON renders |
| 1219 | // "<redacted>", so a missed unwrap here would silently store that | ||
| 1220 | // literal and every later `complete` would fail PKCE validation. | ||
| 1221 | // (The column itself is plaintext; encrypting it is a separate change.) | ||
| 1222 | pkce_verifier: Redacted.value(verifier), | ||
| 1199 | identity_label: input.identityLabel ?? null, | 1223 | identity_label: input.identityLabel ?? null, |
| 1200 | // Persist the requested scope set (declared ∪ client, filtered to the | 1224 | // Persist the requested scope set (declared ∪ client, filtered to the |
| 1201 | // authorization-code flow) so `complete`'s recorded-scope fallback | 1225 | // authorization-code flow) so `complete`'s recorded-scope fallback |
| @@ -1259,7 +1283,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 57 unmodified lines | |||
| 1259 | name: ConnectionName.make(String(sessionRow.name)), | 1283 | name: ConnectionName.make(String(sessionRow.name)), |
| 1260 | template: AuthTemplateSlug.make(String(sessionRow.template)), | 1284 | template: AuthTemplateSlug.make(String(sessionRow.template)), |
| 1261 | redirectUrl: String(sessionRow.redirect_url), | 1285 | redirectUrl: String(sessionRow.redirect_url), |
| 1262 | pkceVerifier: sessionRow.pkce_verifier == null ? null : String(sessionRow.pkce_verifier), | 1286 | // Re-wrapped on the way out of storage: everything downstream treats the |
| 1287 | // verifier as grant material again, and the exchange unwraps it. | ||
| 1288 | pkceVerifier: | ||
| 1289 | sessionRow.pkce_verifier == null ? null : Redacted.make(String(sessionRow.pkce_verifier)), | ||
| 1263 | identityLabel: sessionRow.identity_label == null ? null : String(sessionRow.identity_label), | 1290 | identityLabel: sessionRow.identity_label == null ? null : String(sessionRow.identity_label), |
| 1264 | expiresAt: Number(sessionRow.expires_at), | 1291 | expiresAt: Number(sessionRow.expires_at), |
| 1265 | // The scope set `start` requested (the integration's declared or | 1292 | // The scope set `start` requested (the integration's declared or |
| @@ -1397,11 +1424,16 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 131 unmodified lines | |||
| 1397 | cause: undefined, | 1424 | cause: undefined, |
| 1398 | }); | 1425 | }); |
| 1399 | } | 1426 | } |
| 1427 | // `provider.set` accepts `Redacted` and unwraps at its own write line, so | ||
| 1428 | // the token never becomes a bare string here. | ||
| 1400 | const itemId = accessItemId(target.owner, target.integration, target.name); | 1429 | const itemId = accessItemId(target.owner, target.integration, target.name); |
| 1401 | yield* provider.set(ProviderItemId.make(itemId), token.access_token); | 1430 | yield* provider.set(ProviderItemId.make(itemId), token.access_token); |
| 1402 | 1431 | ||
| 1403 | let refreshItemId: string | null = null; | 1432 | let refreshItemId: string | null = null; |
| 1404 | if (token.refresh_token) { | 1433 | // Absence is `undefined` — never falsiness. Every `Redacted` is truthy, so |
| 1434 | // a truthiness test would stop distinguishing "no refresh token" the day | ||
| 1435 | // the field's presence model changes. | ||
| 1436 | if (token.refresh_token !== undefined) { | ||
| 1405 | refreshItemId = refreshItemIdFor(itemId); | 1437 | refreshItemId = refreshItemIdFor(itemId); |
| 1406 | yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); | 1438 | yield* provider.set(ProviderItemId.make(refreshItemId), token.refresh_token); |
| 1407 | } | 1439 | } |