Unified · Split
| @@ -2709,9 +2709,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2708 unmodified lines | ||
| 2709 | 2709 | ); |
| 2710 | 2710 | |
| 2711 | 2711 | const healthFromCredentialResolutionFailure = ( |
| 2712 | − failure: StorageFailure | CredentialResolutionError, | |
| 2712 | + failure: CredentialResolutionError, | |
| 2713 | 2713 | ): HealthCheckResult => |
| 2714 | − Predicate.isTagged("CredentialResolutionError")(failure) && failure.reauthRequired === true | |
| 2714 | + failure.reauthRequired === true | |
| 2715 | 2715 | ? { |
| 2716 | 2716 | status: "expired", |
| 2717 | 2717 | checkedAt: Date.now(), |
| @@ -2721,20 +2721,24 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines | ||
| 2721 | 2721 | : { |
| 2722 | 2722 | status: "degraded", |
| 2723 | 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 | 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 | 2730 | const oauthCredentialHealthWithoutProbe = ( |
| 2729 | 2731 | row: ConnectionRow, |
| 2730 | − ): Effect.Effect<HealthCheckResult, never> => | |
| 2732 | + ): Effect.Effect<HealthCheckResult, StorageFailure> => | |
| 2731 | 2733 | resolveConnectionValues(row).pipe( |
| 2732 | 2734 | Effect.as({ |
| 2733 | 2735 | status: "healthy" as const, |
| 2734 | 2736 | checkedAt: Date.now(), |
| 2735 | 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 | 2744 | // Resolve an in-flight credential's value map (key-first validation) without |
| @@ -12,6 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "./ids"; |
| 13 | 13 | import { decodeOAuthCallbackState } from "./oauth"; |
| 14 | 14 | import { OAuthStartError } from "./oauth-client"; |
| 15 | +import { missingGrantedOAuthScopes } from "./oauth-service"; | |
| 15 | 16 | import { definePlugin } from "./plugin"; |
| 16 | 17 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 17 | 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 | 470 | key: string, |
| 471 | 471 | ): string | undefined => { |
| 472 | 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 | 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 | 481 | if (!/^[A-Za-z0-9_-]+$/.test(payload) || payload.length % 4 === 1) return null; |
| 480 | 482 | const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); |
| 481 | 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 | 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 | 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 | 232 | const normalizedOAuthScopeSet = (scopes: readonly string[]): ReadonlySet<string> => |
| 217 | 233 | new Set(scopes.map((scope) => canonicalOAuthScope(scope.trim())).filter(Boolean)); |
| 218 | 234 | |
| 219 | −const missingGrantedOAuthScopes = ( | |
| 235 | +export const missingGrantedOAuthScopes = ( | |
| 220 | 236 | requestedScopes: readonly string[], |
| 221 | 237 | recordedScope: string | null, |
| 222 | 238 | ): readonly string[] => { |
| @@ -224,7 +240,9 @@ const missingGrantedOAuthScopes = ( · 1 unmodified line | ||
| 224 | 240 | const seen = new Set<string>(); |
| 225 | 241 | const out: string[] = []; |
| 226 | 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 | 246 | if (scope.length === 0 || informationalOAuthScopes.has(scope) || seen.has(scope)) continue; |
| 229 | 247 | seen.add(scope); |
| 230 | 248 | if (!granted.has(scope)) out.push(scope); |