Unified · Split
| @@ -2709,9 +2709,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2708 unmodified lines | |||
| 2709 | ); | 2709 | ); |
| 2710 | 2710 | ||
| 2711 | const healthFromCredentialResolutionFailure = ( | 2711 | const healthFromCredentialResolutionFailure = ( |
| 2712 | failure: StorageFailure | CredentialResolutionError, | 2712 | failure: CredentialResolutionError, |
| 2713 | ): HealthCheckResult => | 2713 | ): HealthCheckResult => |
| 2714 | Predicate.isTagged("CredentialResolutionError")(failure) && failure.reauthRequired === true | 2714 | failure.reauthRequired === true |
| 2715 | ? { | 2715 | ? { |
| 2716 | status: "expired", | 2716 | status: "expired", |
| 2717 | checkedAt: Date.now(), | 2717 | checkedAt: Date.now(), |
| @@ -2721,20 +2721,24 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines | |||
| 2721 | : { | 2721 | : { |
| 2722 | status: "degraded", | 2722 | status: "degraded", |
| 2723 | checkedAt: Date.now(), | 2723 | checkedAt: Date.now(), |
| 2724 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: failures here carry typed safe messages | 2724 | // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field |
| 2725 | detail: failure.message, | 2725 | detail: failure.message, |
| 2726 | }; | 2726 | }; |
| 2727 | 2727 | ||
| 2728 | // Genuine storage failures propagate: an infra blip must fail the request, | ||
| 2729 | // not persist as a "degraded" verdict on the connection. | ||
| 2728 | const oauthCredentialHealthWithoutProbe = ( | 2730 | const oauthCredentialHealthWithoutProbe = ( |
| 2729 | row: ConnectionRow, | 2731 | row: ConnectionRow, |
| 2730 | ): Effect.Effect<HealthCheckResult, never> => | 2732 | ): Effect.Effect<HealthCheckResult, StorageFailure> => |
| 2731 | resolveConnectionValues(row).pipe( | 2733 | resolveConnectionValues(row).pipe( |
| 2732 | Effect.as({ | 2734 | Effect.as({ |
| 2733 | status: "healthy" as const, | 2735 | status: "healthy" as const, |
| 2734 | checkedAt: Date.now(), | 2736 | checkedAt: Date.now(), |
| 2735 | detail: "Credential resolved (no probe configured).", | 2737 | detail: "Credential resolved (no probe configured).", |
| 2736 | }), | 2738 | }), |
| 2737 | Effect.catch((failure) => Effect.succeed(healthFromCredentialResolutionFailure(failure))), | 2739 | Effect.catchTag("CredentialResolutionError", (failure) => |
| 2740 | Effect.succeed(healthFromCredentialResolutionFailure(failure)), | ||
| 2741 | ), | ||
| 2738 | ); | 2742 | ); |
| 2739 | 2743 | ||
| 2740 | // Resolve an in-flight credential's value map (key-first validation) without | 2744 | // Resolve an in-flight credential's value map (key-first validation) without |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | |||
| 12 | } from "./ids"; | 12 | } from "./ids"; |
| 13 | import { decodeOAuthCallbackState } from "./oauth"; | 13 | import { decodeOAuthCallbackState } from "./oauth"; |
| 14 | import { OAuthStartError } from "./oauth-client"; | 14 | import { OAuthStartError } from "./oauth-client"; |
| 15 | import { missingGrantedOAuthScopes } from "./oauth-service"; | ||
| 15 | import { definePlugin } from "./plugin"; | 16 | import { definePlugin } from "./plugin"; |
| 16 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | 17 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; | 18 | import { serveOAuthTestServer } from "./testing/oauth-test-server"; |
| @@ -1177,3 +1178,45 @@ describe("oauth.complete regional token-endpoint rebind (Datadog multi-site)", ( · 1159 unmodified lines | |||
| 1177 | ), | 1178 | ), |
| 1178 | ); | 1179 | ); |
| 1179 | }); | 1180 | }); |
| 1181 | |||
| 1182 | describe("missingGrantedOAuthScopes canonicalization", () => { | ||
| 1183 | it("treats Microsoft fully-qualified granted scopes as covering short-form requests", () => { | ||
| 1184 | const missing = missingGrantedOAuthScopes( | ||
| 1185 | [ | ||
| 1186 | "offline_access", | ||
| 1187 | "User.Read", | ||
| 1188 | "https://graph.microsoft.com/.default", | ||
| 1189 | "Mail.ReadWrite", | ||
| 1190 | "Mail.Send", | ||
| 1191 | "MailboxSettings.ReadWrite", | ||
| 1192 | ], | ||
| 1193 | [ | ||
| 1194 | "https://graph.microsoft.com/Mail.ReadWrite", | ||
| 1195 | "https://graph.microsoft.com/Mail.Send", | ||
| 1196 | "https://graph.microsoft.com/MailboxSettings.ReadWrite", | ||
| 1197 | "https://graph.microsoft.com/User.Read", | ||
| 1198 | ].join(" "), | ||
| 1199 | ); | ||
| 1200 | expect(missing).toEqual([]); | ||
| 1201 | }); | ||
| 1202 | |||
| 1203 | it("still reports genuinely ungranted scopes under the qualified Microsoft shape", () => { | ||
| 1204 | const missing = missingGrantedOAuthScopes( | ||
| 1205 | ["User.Read", "Mail.ReadWrite", "Mail.Send"], | ||
| 1206 | "https://graph.microsoft.com/User.Read https://graph.microsoft.com/Mail.ReadWrite", | ||
| 1207 | ); | ||
| 1208 | expect(missing).toEqual(["Mail.Send"]); | ||
| 1209 | }); | ||
| 1210 | |||
| 1211 | it("maps Google userinfo aliases and ignores identity meta-scopes", () => { | ||
| 1212 | const missing = missingGrantedOAuthScopes( | ||
| 1213 | ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"], | ||
| 1214 | [ | ||
| 1215 | "https://www.googleapis.com/auth/userinfo.email", | ||
| 1216 | "https://www.googleapis.com/auth/userinfo.profile", | ||
| 1217 | "https://www.googleapis.com/auth/calendar", | ||
| 1218 | ].join(" "), | ||
| 1219 | ); | ||
| 1220 | expect(missing).toEqual([]); | ||
| 1221 | }); | ||
| 1222 | }); | ||
| @@ -470,7 +470,9 @@ const stringClaim = ( · 469 unmodified lines | |||
| 470 | key: string, | 470 | key: string, |
| 471 | ): string | undefined => { | 471 | ): string | undefined => { |
| 472 | const value = claims[key]; | 472 | const value = claims[key]; |
| 473 | return typeof value === "string" && value.trim().length > 0 ? value : undefined; | 473 | if (typeof value !== "string") return undefined; |
| 474 | const trimmed = value.trim(); | ||
| 475 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 474 | }; | 476 | }; |
| 475 | 477 | ||
| 476 | const decodeJwtPayload = (token: string): Readonly<Record<string, unknown>> | null => { | 478 | const decodeJwtPayload = (token: string): Readonly<Record<string, unknown>> | null => { |
| @@ -479,7 +481,12 @@ const decodeJwtPayload = (token: string): Readonly<Record<string, unknown>> | nu · 2 unmodified lines | |||
| 479 | if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) return null; | 481 | if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) return null; |
| 480 | const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); | 482 | const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); |
| 481 | const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); | 483 | const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); |
| 482 | const decoded = decodeJwtClaims(globalThis.atob(padded)); | 484 | // atob yields latin1 code units; JWT payloads are UTF-8 bytes, so re-decode |
| 485 | // them properly or non-ASCII claim values (accented emails, names) garble. | ||
| 486 | const utf8 = new TextDecoder().decode( | ||
| 487 | Uint8Array.from(globalThis.atob(padded), (char) => char.charCodeAt(0)), | ||
| 488 | ); | ||
| 489 | const decoded = decodeJwtClaims(utf8); | ||
| 483 | return Option.isSome(decoded) ? decoded.value : null; | 490 | return Option.isSome(decoded) ? decoded.value : null; |
| 484 | }; | 491 | }; |
| 485 | 492 | ||
| @@ -211,12 +211,28 @@ const OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = { · 210 unmodified lines | |||
| 211 | 211 | ||
| 212 | const informationalOAuthScopes = new Set(["openid", "email", "profile", "offline_access"]); | 212 | const informationalOAuthScopes = new Set(["openid", "email", "profile", "offline_access"]); |
| 213 | 213 | ||
| 214 | const canonicalOAuthScope = (scope: string): string => OAUTH_SCOPE_ALIASES[scope] ?? scope; | 214 | /** Canonicalize a scope for granted-vs-requested comparison. Microsoft's token |
| 215 | * endpoint returns Graph scopes fully qualified | ||
| 216 | * (`https://graph.microsoft.com/Mail.ReadWrite`) even when the request used | ||
| 217 | * the short form, so resource-URI prefixes are stripped down to the scope's | ||
| 218 | * final path segment before comparing. */ | ||
| 219 | const canonicalOAuthScope = (scope: string): string => { | ||
| 220 | const aliased = OAUTH_SCOPE_ALIASES[scope]; | ||
| 221 | if (aliased) return aliased; | ||
| 222 | if (/^https?:\/\/graph\.microsoft\.(com|us|de)\//i.test(scope)) { | ||
| 223 | return scope.slice(scope.lastIndexOf("/") + 1); | ||
| 224 | } | ||
| 225 | return scope; | ||
| 226 | }; | ||
| 227 | |||
| 228 | /** `.default` is a request-time meta-scope (Microsoft expands it server-side | ||
| 229 | * and never echoes it in the granted scope), so it can never be "missing". */ | ||
| 230 | const isMetaOAuthScope = (scope: string): boolean => scope.toLowerCase().endsWith("/.default"); | ||
| 215 | 231 | ||
| 216 | const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> => | 232 | const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> => |
| 217 | new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean)); | 233 | new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean)); |
| 218 | 234 | ||
| 219 | const missingGrantedOAuthScopes = ( | 235 | export const missingGrantedOAuthScopes = ( |
| 220 | requestedScopes: readonly string[], | 236 | requestedScopes: readonly string[], |
| 221 | recordedScope: string | null, | 237 | recordedScope: string | null, |
| 222 | ): readonly string[] => { | 238 | ): readonly string[] => { |
| @@ -224,7 +240,9 @@ const missingGrantedOAuthScopes = ( · 1 unmodified line | |||
| 224 | const seen = new Set<string>(); | 240 | const seen = new Set<string>(); |
| 225 | const out: string[] = []; | 241 | const out: string[] = []; |
| 226 | for (const raw of requestedScopes) { | 242 | for (const raw of requestedScopes) { |
| 227 | const scope = canonicalOAuthScope(raw.trim()); | 243 | const trimmed = raw.trim(); |
| 244 | if (isMetaOAuthScope(trimmed)) continue; | ||
| 245 | const scope = canonicalOAuthScope(trimmed); | ||
| 228 | if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue; | 246 | if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue; |
| 229 | seen.add(scope); | 247 | seen.add(scope); |
| 230 | if (!granted.has(scope)) out.push(scope); | 248 | if (!granted.has(scope)) out.push(scope); |