Repository navigation
Ptech/sync upstream main - #2
Merged
Merged
Conversation
…daya-Dev#301) (Bdaya-Dev#390) The store's non-secure persistence now uses the modern `SharedPreferencesAsync` API by default instead of the legacy synchronous `SharedPreferences`. This lets callers share their own async preferences instance with the store, as requested in Bdaya-Dev#301. Mechanism: - New `sharedPreferencesAsync` constructor parameter accepts (and injects, for tests) a `SharedPreferencesAsync` instance. - The legacy `sharedPreferences` parameter is kept and still works end-to-end, but is now `@Deprecated` (pointing at the async replacement), following the repo's existing deprecation style. - Backend selection is abstracted behind a small internal `_OidcPrefsBackend` interface with legacy and async adapters, so the rest of the store stays backend-agnostic; all non-secure reads/writes are now awaited. - On the default path (no instance injected), `init()` runs a one-time, best-effort migration of only this store's own (`storagePrefix`-scoped) keys from the legacy store into `SharedPreferencesAsync`, guarded by a marker so it runs once and never overwrites existing async values. This preserves data on Android, where the legacy API (SharedPreferences) and `SharedPreferencesAsync` (DataStore Preferences) use different backing stores; on iOS/macOS/web/desktop both share one store so it is a no-op copy. Dependency: bump `shared_preferences` constraint `^2.2.1` -> `^2.3.0` (the first release shipping `SharedPreferencesAsync`). Adds a test-only `shared_preferences_platform_interface` dev dependency for `InMemorySharedPreferencesAsync` (in-memory async platform for VM tests). Tests cover: default async round-trip, injected async instance, the deprecated legacy-constructor path still working, scoped one-time legacy->async migration, and migration not clobbering existing async values. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…aya-Dev#324) (Bdaya-Dev#391) RFC 9449 §10 defines the `dpop_jkt` authorization-request parameter that binds the issued authorization code to the DPoP proof key when Pushed Authorization Requests are NOT used. The binding was previously emitted only on the back-channel PAR request body, so a DPoP-enabled direct (non-PAR) authorization request left the code unbound. `loginAuthorizationCodeFlow` now resolves the PAR push decision up front and, when DPoP is enabled with `bindAuthorizationCode` and the flow is not pushing, threads the same key thumbprint (`OidcDPoPManager.thumbprint` — the identical source the PAR path derives) into `prepareAuthorizationCodeFlowRequest`. The thumbprint is set on the new `OidcAuthorizeRequest.dpopJkt` member before any JWT-Secured Authorization Request object is built, so it rides the front-channel URL and, when JAR is used, inside the signed request object. The direct-request and PAR-body binding sites are mutually exclusive: the code is never double-bound and the PAR path is unchanged. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ant) (Bdaya-Dev#389) * fix(oidc_core): honor expectedIssuer when validating id_token issuer (Bdaya-Dev#168) Microsoft Entra ID multi-tenant (`common`/`organizations`) advertises a non-substituted template issuer (`https://login.microsoftonline.com/{tenantid}/v2.0`) that can never equal the concrete per-tenant `iss` a real id_token carries, so `validateUser`'s exact-match `iss` check (jose `claims.validate`, OIDC Core §3.1.3.7 step 2) always failed with "Issuer does not match". Introduce `resolveExpectedIssuer(metadata)` — mirroring the existing `resolveAllowedIdTokenAlgorithms` convention — which returns `settings.expectedIssuer ?? metadata.issuer`, and use it as the issuer `validateUser` validates against. This lets a multi-tenant RP pin the concrete tenant issuer via the existing `expectedIssuer` setting (added in the audit hardening) instead of hacking the discovery document after `init()`. Defaults are unchanged: with no `expectedIssuer` set the advertised `metadata.issuer` is used exactly as before, so single-tenant behavior is identical. Pinned by an Entra-shaped scenario (template metadata issuer + concrete `iss`): passes with `expectedIssuer` set, and a regression pin asserts it still fails without it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * test(oidc_core): pin exact-match issuer rejection; resolve expectedIssuer on signed UserInfo — review follow-up BLOCKER (security-negative test the reviewer mutation-tested for): in the Bdaya-Dev#168 group, add a case where `expectedIssuer` pins concrete tenant A while the id_token carries a DIFFERENT concrete tenant B (B != A, B != the template metadata issuer). §3.1.3.7 requires an EXACT match, so pinning A MUST still reject B — the pin is not "accept any non-template issuer". Asserts the `validateUser` errors contain 'Issuer does not match'. ADVISORY: the signed-UserInfo §5.3.4 `iss` check (validateSignedResponseClaims / requireSignedResponseIssAud) compared the verified UserInfo `iss` against the raw (possibly template) `metadata.issuer`. The UserInfo `iss` MUST match the id_token `iss`, which for a multi-tenant OP is the concrete per-tenant issuer, so resolve it through the same `resolveExpectedIssuer(metadata)` pin used for the id_token `iss` check. Adds two manager-level tests driving the full validate + UserInfo path: one pinning that a concrete-tenant signed UserInfo is applied when `expectedIssuer` pins it (fails if compared to the template — mutation-verified), and one pinning that a WRONG concrete UserInfo `iss` is still rejected (never merged) when `expectedIssuer` is set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…conds (Bdaya-Dev#369) (Bdaya-Dev#388) The interactive login/logout loopback wait had no flow timeout, so the CLI hung forever if the browser flow was abandoned (the user closes the tab, or no browser opens), also leaking the bound loopback socket. `CliUserManager.startListenerAndGetUri` now threads the caller's `OidcPlatformSpecificOptions` and resolves a per-OS `flowTimeoutSeconds` (windows/linux/macos), passing it to `listenForSingleResponse` — which already force-closes the socket and throws a `TimeoutException` on expiry. That exception is wrapped in an `OidcException` (message contains "timed out") after a user-facing warning, mirroring `oidc_desktop`'s convention exactly, including its default semantics: `null`/non-positive means an unbounded wait, so existing behavior is unchanged unless the setting is opted into. Both `getAuthorizationResponse` and `getEndSessionResponse` now forward their `options` to the listener. Pinned by a new `cli_user_manager_test` case that simulates an abandoned flow (the listener never receives a redirect) and asserts the wait completes with a timeout `OidcException` instead of hanging. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…; clamp the expiring-refire loop (Bdaya-Dev#392) * feat(oidc_core): emit token refresh failure events; clamp expiring lead (Bdaya-Dev#120, Bdaya-Dev#123, Bdaya-Dev#154) Adds OidcTokenRefreshFailedEvent to events(), emitted at all three refresh failure sites (auto-expiry, manual refreshToken(), startup cached-load), so apps can observe refresh failures without subscribing to userChanges() (which is a value stream and cannot carry errors). The event classifies failures as transient (network/5xx — liveness unknown, retryable, session kept) vs terminal (invalid_grant per RFC 6749 §5.2 — refresh token dead, re-auth required) and carries the parsed oauth error code, HTTP status, and whether a retry was scheduled. The manual path keeps its throw (MSAL-style double-signal). Bdaya-Dev#123: clamp the effective expiring-notification lead to min(refreshBefore, half-life) and never fire 'expiring' synchronously during load(); a lead larger than the token lifetime otherwise drove a tight infinite refresh loop. Bdaya-Dev#154: emit the failure event before entering offline mode / tearing down timers, and never forget the user on a transient failure. Offline mode entered due to a failed refresh now reports OfflineModeReason.tokenRefreshFailed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * fix(oidc_core): dedupe startup refresh-failure event; stop resume forget-race (Bdaya-Dev#120, Bdaya-Dev#154) BLOCKER 1 (Bdaya-Dev#120): the startup cached-load path emitted TWO OidcTokenRefreshFailedEvents per failure (source=manual from _refreshToken, then source=startupLoad from loadCachedTokens) and ran handleOfflineEligibleFailure twice. _refreshToken now takes an OidcTokenRefreshSource (default manual); loadCachedTokens passes startupLoad and no longer re-emits the event or re-runs offline handling, so each startup refresh failure yields exactly one event with the correct source and one offline-handling pass. Offline-absorbed failures still return the cached user; terminal/offline-disabled failures still rethrow to the outer catch that clears invalid tokens. BLOCKER 2 (Bdaya-Dev#154): with the default supportOfflineAuth=false, handleTokenExpired called forgetUser() unconditionally on expiry, which on resume (both timers overdue) raced and beat the async refresh, clearing a user whose refresh token was still valid. When the expired token still carries a refresh token, the forget decision is now deferred to the refresh outcome via a single shared in-flight future (_autoRefreshInFlight) that handleTokenExpiring and handleTokenExpired both latch onto (exactly one refresh): SUCCESS keeps the replaced user; TERMINAL failure forgets (the failure event precedes the null userChange); TRANSIENT failure retains, matching the non-offline expiring-path retention. Tokens without a refresh token keep today's immediate forgetUser. Tests: t7 (single startupLoad event), t8/t9/t10 (resume-race success / terminal / no-refresh-token); t6 relabeled to what it pins (the expiring-path retention); the internals handleTokenExpired-forgets test now drives a terminal failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * fix(oidc_core): make deferred auto-refresh close-safe after dispose() The Bdaya-Dev#120 deferred-refresh machinery (handleTokenExpired -> _autoRefresh -> _performAutoRefresh) let the async refresh outlive dispose(): when its (possibly delayed) token response landed after the manager was closed it called eventsController.add, throwing "Bad state: Cannot add new events after calling close" (caught in CI on oidc_cli status_command_test 'warns when the session token is already expired'). The success path was equally unsafe — it would save/replace the user on a torn-down manager. - Route every event emission through a new close-tolerant emitEvent() helper that no-ops when eventsController.isClosed, mirroring OidcValueStream.add's documented close-tolerance. - Add an _isDisposed flag (set at the very start of dispose()) and gate the post-await outcome of _performAutoRefresh on it: after dispose the in-flight refresh is a COMPLETE no-op — no event, no createUserFromToken save/mutation, no offline handling, no retry timer. handleTokenExpired's terminal-forget continuation is likewise gated so it never forgets a disposed manager. - dispose() latches the shared in-flight refresh future and swallows its settling with logger.finest so it can't surface an unhandled zone error. Regression tests (oidc_core/test/post_dispose_refresh_test.dart) reproduce the CLI scenario at library level for both a delayed-success and a delayed-terminal -failure refresh; both fail against the pre-fix code (user replaced / exact "Bad state" thrown) and pass with the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * test(oidc): pump past the clamped expiring instant in no-id_token refresh test The Bdaya-Dev#123 half-life clamp bounds the effective expiring lead to at most half the token lifetime. With this test's 1s cached token and 700ms configured refreshBefore, the lead clamps to 500ms, so the expiring event — and the auto-refresh it drives — now fires ~500ms after load instead of at 1000 - 700 = 300ms. The 450ms observation window no longer captured the single refresh, so refreshRequestCount was 0. Widen the first window to 700ms (past the clamped 500ms instant, still before the original 1000ms expiry). The test's intent is unchanged: exactly one refresh, exactly one expiring event, no expired event, retained user with an allowed expired id_token. This escaped because the oidc_core-only gate skipped the dependent oidc package that owns this test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…a-Dev#116, Bdaya-Dev#227, Bdaya-Dev#256) (Bdaya-Dev#397) The bundled `redirect.html` no longer hardcodes "Operation Successful". It now posts the incoming redirect to the app as a structured envelope `{"v":2,"type":"redirect","uri":...}` on the BroadcastChannel and waits for the app to acknowledge with `{"v":2,"type":"ack","status":"ok"|"error"}` before choosing a terminal state, fixing the misleading success message when the app-side token exchange failed (Bdaya-Dev#116). The page is now parse-first and state-driven: it reads `error`/`error_description` /`error_uri` from both the query and the fragment (RFC 6749 §4.1.2.1) and renders an error state directly; otherwise it shows success only on an `ok` ack, an app-error on an `error` ack, and a neutral "you can now close this tab" fallback when no ack arrives within ~10s or the browser refuses to close a script-unopened (email-link) tab (Bdaya-Dev#227). Front-channel logout and same-page self-navigation keep distinct states, and there's a graceful message when BroadcastChannel is missing. The app consumer accepts both the v2 envelope and the legacy bare-URL string, so an older copied page keeps working after an upgrade; `getAuthorizationResponse` and `getEndSessionResponse` post the matching ack. Both example template copies are updated in sync. Docs gain a re-copy/upgrade note and a troubleshooting entry for a `redirect_uri` that never serves the page (e.g. a 502 at the proxy, Bdaya-Dev#256). Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…daya-Dev#124) (Bdaya-Dev#398) Adds an opt-in OidcAppleNavigationMode.loopbackSystemBrowser to OidcNativeOptionsApple. On macOS this runs the authorization / end-session flow in the default system browser (url_launcher) and captures the redirect on an RFC 8252 section 7.3 loopback listener (http://127.0.0.1:{port}), reusing the oidc_loopback_listener transport and mirroring oidc_desktop's ephemeral-port rewrite and flowTimeoutSeconds behavior. iOS ignores the mode (ASWebAuthenticationSession remains the only iOS mechanism). Also adds the loopback page-response sub-options (successfulPageResponse, methodMismatchResponse, notFoundResponse) and a launchUrl test seam to OidcNativeOptionsApple, and documents the mode, redirect-uri registration, and App Sandbox entitlements in the oidc_darwin README. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ya-Dev#370) (Bdaya-Dev#401) packages/oidc_web/lib/oidc_web.dart (the real OidcWeb plugin class) imports oidc_web_core -> package:web (dart:js_interop), so it only compiles for a browser target. CI's Flutter unit path is plain `flutter test`, which runs on the VM ("tester") platform, so test/library_surface_test.dart's @teston('chrome') suite was silently skipped there (0 tests loaded, no failure) and oidc_web.dart never appeared in any CI unit report. `flutter test -d chrome` does not fix this: verified empirically that -d/--device-id is ignored by `flutter test`, which still spawns the native flutter_tester (VM) process regardless of the device given. `--platform chrome` is the only invocation that actually threads a browser Runtime through suite-load filtering (confirmed locally: the suite reaches "loading ..." under --platform chrome, never under -d chrome). The flag is marked deprecated/hidden ("intended for testing the Flutter framework itself"), but there is no non-deprecated alternative today. Add melos script test:oidc_web:chrome (flutter test --platform chrome, scoped to oidc_web) and a CI step running it on both main and PR builds -- oidc_web has exactly one browser-tagged suite file today, so a single invocation covers it, no per-file loop needed. Also fix the test file's own header comment, which recommended the broken `flutter test -d chrome` invocation. Verified locally via `dart run melos run test:oidc_web:chrome` and direct `flutter test --platform chrome` runs: the suite is reached and loaded (proving the CI wiring gap is closed). Full local pass is blocked by a long-standing, Windows-only flutter_tools bug unrelated to project code (flutter/flutter#162798, #44583, #118778); this job runs on ubuntu-latest, not the affected platform. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…-Dev#302) (Bdaya-Dev#403) A UserInfo request rejected by the resource server with `401` (RFC 6750 §3) while resuming an established session used to be swallowed: the cached id_token still validated, so the user was saved without UserInfo and no signal reached the app. A revoked access token paired with a live refresh token is recoverable, and even an unrecoverable rejection should be observable. `validateAndSaveUser` gains `reactToUserInfoUnauthorized`, enabled only on the cached-session resume path (never on initial login or a just-refreshed token). On a UserInfo 401 it: - parses the `WWW-Authenticate` challenge (RFC 6750 §3 error codes plus RFC 9470 step-up hints) via the existing OidcStepUpChallenge parser; - attempts exactly one refresh-token grant when a refresh token exists, reusing the Bdaya-Dev#120 refresh machinery (which retries UserInfo once on success and emits OidcTokenRefreshFailedEvent on failure); - emits a new typed OidcUserInfoFailedEvent on events() only when recovery is not possible or does not succeed. No automatic forgetUser, consistent with the Bdaya-Dev#120 terminal-retention default: the app listens for the event and decides logout vs. RFC 9470 step-up. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…arsing (Bdaya-Dev#400) deleteClientConfiguration accepted any 2xx/3xx status as success (including 302 redirects) and threw a plain OidcException on failure, bypassing the typed OidcErrorResponse parsing used by registerClient/readClientConfiguration /updateClientConfiguration. Per RFC 7592 2.3, success is HTTP 204 (200 accepted leniently for servers that use it instead); every other status now decodes the body and, when it carries an RFC 6749 error field, throws a typed OidcException.serverError so 401/403/405 responses surface parsed error fields to callers. Fixes Bdaya-Dev#387. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…item 20) (Bdaya-Dev#402) The PKCE code_verifier was persisted in the `state` namespace, which is plaintext on every backend (localStorage on web, SharedPreferences on mobile/desktop). Anyone who scrapes that store together with the authorization code can complete the token exchange (RFC 7636 §1). Persist it in the `secureTokens` namespace instead — encrypted at rest on web via OidcWebStore and secure-storage-backed on mobile/desktop via OidcDefaultStore — keyed by the state id, the same posture the nonce already gets. It is stripped from the state payload before that payload is written. The redirect reader prefers the secureTokens value and falls back to the (now legacy) in-payload value for one release, so a login already in flight when the app upgrades still completes. The entry is dropped when the flow completes and when a stale state is cleared, so the secret never outlives the short-lived flow. New store helpers setStateCodeVerifier/getStateCodeVerifier own the namespaced key. Tests pin the write location, the round-trip through the new location, the legacy-payload fallback, cleanup, and that the web store persists it as ciphertext. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…cache, loaded-token validity controls (Bdaya-Dev#399) * feat(oidc_core): cache-first init default + discovery TTL + loaded-token validity controls (Bdaya-Dev#201, Bdaya-Dev#205) Rebased onto origin/main (1.0.0 + Bdaya-Dev#388-Bdaya-Dev#392). Reconciled with Bdaya-Dev#392's restructure of loadCachedTokens/init: the startup refresh now delegates failure signalling and offline handling to _refreshToken(source: startupLoad) — a single OidcTokenRefreshFailedEvent instead of the branch's prior outer try/catch + handleOfflineEligibleFailure double signal — while preserving this branch's Bdaya-Dev#205 isLoadedTokenAcceptable / shouldRemoveInvalidToken controls and cache-first init/TTL. The refactored attachLifecycleListeners() routes native browser events through Bdaya-Dev#392's close-safe emitEvent rather than eventsController.add. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * fix(oidc_core): single-refresh cache-first cold start; retract reject-but-keep user (Bdaya-Dev#201, Bdaya-Dev#205) BLOCKER 1: on a cacheFirst cold start with an expired-but-refreshable cached token, the restored expired user armed the expiry timers (path A: _autoRefresh, latched) while the background revalidation also refreshed (path B: loadCachedTokens -> _refreshToken source=startupLoad, which bypassed the latch). Two concurrent /token exchanges could trip an OP's RFC 9700 refresh-rotation reuse detection and revoke the token family on an ordinary reopen. Suppress the expiry-driven auto-refresh while the background pass owns the first refresh (new _cacheFirstRevalidationInFlight gate), so the DEFAULT mode performs exactly ONE /token call and emits exactly ONE failure event (source=startupLoad). Undoes the prior masking that pinned t7 to blockingValidate; adds t7b under the default mode plus success/terminal-failure cold-start tests. BLOCKER 2: the background reconciliation only retracted the surfaced user when the on-disk token had been removed, so a policy-REJECTED token kept on disk (shouldRemoveInvalidToken=false or supportOfflineAuth=true) left the rejected user logged in — diverging from blockingValidate. Decouple the in-memory retraction from disk removal: whenever loadCachedTokens did not replace the surfaced user (currentUser still identical to the restored object), retract it in-memory without clearing the kept-on-disk tokens. Re-run the Bdaya-Dev#205 validity-callback matrix under BOTH modes. SHOULD-FIX 3/4/5: qualify the blockingValidate dartdoc/migration note (exact pre-1.0 network behavior also needs discoveryDocumentMaxAge: Duration.zero; TTL default unchanged); document the intentional cold-start userChanges double-emission (dedup is unsafe — it would suppress the verified-user surface); add a test pinning that the persisted discovery copy excludes in-memory metadataSeed values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add OAuth 2.0 Dynamic Client Registration integration pieces on top of the existing RFC 7591/7592 endpoints: - OidcClientAuthentication.fromRegistrationResponse: derive a ready-to-use client authentication from a registration response, mapping token_endpoint_auth_method (+ credential presence) to none / client_secret_basic / client_secret_post / client_secret_jwt (generated), defaulting to client_secret_basic per RFC 7591 §2, and throwing OidcException for private_key_jwt (key is client-held, never in the response) and unknown/unsupported methods. - Typed OpenID Connect Dynamic Client Registration 1.0 §2 metadata fields on OidcClientRegistrationRequest: default_max_age, require_auth_time, default_acr_values, initiate_login_uri, request_uris (regenerated .g.dart). application_type / subject_type / id_token_signed_response_alg / sector_identifier_uri were already present. - OidcClientRegistrationResponse.toUpdateRequest: build the full-metadata RFC 7592 §2.2 PUT body, dropping server-managed fields and documenting client_secret / registration_access_token rotation on the update response. Coordination: the tls_client_auth_* subject params are added by the sibling mTLS lane on request.dart and are intentionally not added here to avoid a conflict. Deferred Bdaya-Dev#385 follow-ups (not in this PR): register -> OidcUserManager.fromRegistration bootstrap and a dedicated registration-credential store namespace. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…daya-Dev#412) Add the platform-agnostic (dart:io-free) core surface for mutual-TLS client authentication (RFC 8705), consumable by oidc_web: - OidcClientAuthentication.tlsClientAuth / .selfSignedTlsClientAuth named ctors (§2.1 / §2.2): header-less, emit only client_id in the body. - OidcConstants_ClientAuthenticationMethods.tlsClientAuth + selfSignedTlsClientAuth. - OidcMtlsEndpointAliases typed view over mtls_endpoint_aliases (§5) plus OidcProviderMetadata.mtlsEndpointAliases, tlsClientCertificateBoundAccessTokens[OrDefault] (§3.3), and a single resolveEndpoint(name, {useMtlsAliases}) choke point applying the alias-or-fallback rule (§5). - OidcUserManagerSettings.useMtlsEndpointAliases (default false, explicit opt-in, not auto-inferred from the auth method). - OidcClientRegistrationRequest: tls_client_auth_subject_dn / _san_dns / _san_uri / _san_ip / _san_email (§2.1.2) + tls_client_certificate_bound_access_tokens (§3.4). Non-goals (deferred to phase 2, native layer): cert-bearing http.Client / dart:io SecurityContext transport; cnf x5t#S256 validation; web throw-on-mTLS. The manager call sites are intentionally NOT rewired onto resolveEndpoint in this PR (that touches ~20 read sites); the helper is the introduced choke point ready for phase-2 wiring. Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore(release): publish packages - oidc@2.0.0 - oidc_cli@1.0.1 - oidc_core@2.0.0 - oidc_darwin@1.1.0 - oidc_default_store@1.1.0 - oidc_web_core@1.1.0 - oidc_android@1.0.1 - oidc_desktop@1.0.1 - oidc_linux@1.0.1 - oidc_platform_interface@1.0.1 - oidc_web@1.0.1 - oidc_windows@1.0.1 * chore(release): regenerate oidc_cli version.dart for 1.0.1 The melos release flow bumps pubspec.yaml but does not run build_runner, so the build_version-generated version.dart lagged the 1.0.1 bump. Regenerated so ensure_build passes and the CLI reports its true version. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: ahmednfwela <ahmednfwela@bdaya-dev.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ixes Bdaya-Dev#416) (Bdaya-Dev#417) * build(melos): regenerate build_version files during `melos version` (fixes Bdaya-Dev#416) `melos version` bumps each package's pubspec.yaml but never runs build_runner, so oidc_cli's build_version-generated lib/src/version.dart lags behind the released version. Add a version-command `preCommit` hook that runs `melos run generate:dart` and then explicitly `git add packages/oidc_cli/lib/src/version.dart`. The explicit `git add` is required: melos's `_gitStageChanges` only stages the aggregate changelogs and the pubspec.yaml / CHANGELOG.md it edits itself, never files a preCommit hook regenerates. Without the add, the freshly regenerated version.dart is left unstaged and every release commit ships a stale version, so the hook would be inert. The hook is a single line joined with `&&` rather than a multi-line `|` block on purpose: melos runs hooks through `sh -c 'eval "$MELOS_SCRIPT"'` on POSIX but `cmd.exe /C %MELOS_SCRIPT%` on Windows, and cmd truncates the command at the first embedded newline -- a `|` block would silently run only generate:dart and drop the git add on Windows. Verified end-to-end against melos 7.8.2: a scratch `melos version -V oidc_cli:1.0.2 --no-git-commit-version` run fires the hook and stages version.dart with the bumped value (git diff --cached shows the change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU * docs(melos): correct the hook comment's melos version reference (7.8.2 -> 7.8.0) The resolved version in pubspec.lock is melos 7.8.0; the comment misstated it. Mechanics are unchanged and identical across those versions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McVLYD2qr9r6Hec2MpKZaU --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…re path (Bdaya-Dev#418) (Bdaya-Dev#419) * fix(oidc_android)!: restore the redirect receiver as a fallback capture path Auth Tab is not a universal capture path. AuthTabIntent performs no capability detection: launch() only stuffs extras onto an Intent, and Builder.build() deliberately carries a null EXTRA_SESSION "so that this is interpreted as a Custom Tab intent by browser implementations that don't support Auth Tab". That fallback preserves the UI, not the redirect capture -- a plain Custom Tab does not intercept the redirect_uri, it merely navigates to it. Since 05bf018 removed OidcRedirectActivity and its intent-filter, that navigation resolved to no app on every browser except Chrome 137+, leaving the user on a blank tab. See Bdaya-Dev#418. Redirect capture is dual-path again: Auth Tab stays preferred where supported (its result survives process death), and OidcRedirectActivity catches the redirect everywhere else. Whichever fires first wins; finishPending() resolves the Dart callback exactly once. A plain FlutterActivity host works again instead of failing with NO_COMPONENT_ACTIVITY. BREAKING CHANGE: apps must set the oidcRedirectScheme manifest placeholder, as the README and getting-started docs have always instructed. 2.0.0 silently made it a no-op. The library ships NO default value on purpose: a placeholder declared in a library module is substituted into the library manifest before the app merge and would shadow the app's own scheme (verified by building the example -- a default of 'com.bdayadev.oidc.unset' produced exactly that in the merged manifest). An app that omits it now fails the manifest merge naming the placeholder, instead of failing silently at runtime. Also fixed: - minSdkVersion 21 -> 23; androidx.browser:1.10.0 declares 23, so the old value was a manifest-merge failure waiting to happen in consuming apps. - captureMode now emits the customTabsRedirectActivity value that oidc_platform_interface's parser actually recognises; anything else decoded silently to OidcRedirectCaptureMode.unknown. Test gaps that let this ship, all closed: - shared_e2e.dart called loginAuthorizationCodeFlow() but never asserted the result -- a null only logged a warning and exceptions were caught and logged, so the Android E2E reported green whether or not a redirect was captured. - oidc_android had no native tests at all (android/src/ held only main/). Adds a Robolectric suite covering intent-filter capture, scheme/host matching, once-only resolution, flow supersession, and a cross-language guard that the emitted captureMode strings match the Dart enum. - The CI emulator used the default AOSP image, which ships no browser, so the browser leg could never run. Switches to google_apis_playstore and wires the native suite into the workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(ci): generate the gradle wrapper before the native Android tests gradlew and gradle-wrapper.jar are gitignored repo-wide (only gradle-wrapper.properties is tracked), so a fresh CI checkout has no wrapper and the new native test step died with `./gradlew: No such file or directory` (exit 127). `flutter build apk --config-only` generates the android build files without building an artifact; verified locally by deleting both files and confirming --config-only recreates them. Also makes a conformance login failure legible. The harness truncates a raw exception to "1 test(s) failed", and the Dart logger is captured into the certification archive rather than CI stdout, so neither the module name nor the reason reached the job output. A throw is now reported via fail() naming the module, and the null/non-null outcome is print()ed so it survives to stdout. This does not weaken the assertion added for Bdaya-Dev#418 -- it only makes the failure diagnosable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(ci): format shared_e2e.dart and make the linux patrol failure legible The Format verify step failed on the previous commit -- `dart format` wraps the new diagnostic print(). Reformatted. Adds --verbose to the linux patrol invocation (the ios job already runs verbose). patrol forwards no test stdout to the job log by default: the conformance failure reports only "1 test(s) failed" with the reason, the module name, and even plain print() output all dropped, which makes the previous commit's diagnostics invisible. This is diagnostic only -- no test behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(test): assert the aggregate login count, not every module The previous per-module `expect(authResult, isNotNull)` was wrong. The oidcc-client-basic-certification-test-plan mixes POSITIVE modules with NEGATIVE ones -- oidcc-client-test-invalid-iss and friends, where the OP deliberately returns a broken response and the client is REQUIRED to reject it. A null result there is the correct outcome, so requiring every module to produce a user fails the suite on conformant behaviour. The --verbose run showed exactly this: [e2e] oidcc-client-test -> authResult ok [e2e] oidcc-client-test-invalid-iss -> authResult NULL <- assertion fired Login does complete; the assertion was simply invalid for module 2. Replaced with an aggregate: at least one module must complete a login. That keeps the Bdaya-Dev#418 regression signal intact -- a platform that cannot capture the browser redirect scores zero, which is the whole failure mode -- without encoding which of the 14 modules are negative, so it stays correct if the plan changes. Per-module outcomes are still printed for diagnosis, and an exception is recorded rather than fatal since negative modules may legitimately throw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(ci): give the Android example an androidTest source set so tests actually run The Android integration job has executed ZERO tests for its entire life while reporting success. On main's last green run it printed: Test summary: Total: 0 Successful: 0 Failed: 0 Skipped: 0 Duration: 7s with no "Discovered N test(s)", no "Results:", no PATROL_LOG anywhere in the 136KB log -- while the linux log contains all three. This is why the suite could not catch Bdaya-Dev#418: the one job that should have caught an Android redirect regression had never run a test. Root cause: packages/oidc/example/android/app/src contained only debug, main and profile -- no androidTest source set at all -- and build.gradle declared the stock androidx.test.runner.AndroidJUnitRunner. So connectedDebugAndroidTest enumerated zero JUnit classes, patrol's Dart-side listDartTests() RPC was never reached, and the run finished in 7s. iOS is the control case: it HAS the analogue (ios/RunnerUITests/RunnerUITests.m) and correctly reports 3-4 tests. patrol's CLI does not generate this file; it only mentions it in a comment. Adds: - androidTest/java/com/bdayadev/oidc/example/MainActivityTest.java, the Parameterized JUnit entrypoint that calls listDartTests(). Passes FlutterFragmentActivity because the example declares no custom MainActivity (the manifest launches the Flutter activity directly); it is only the fallback path anyway, since PatrolJUnitRunner.setUp prefers getLaunchIntentForPackage and the manifest does declare a LAUNCHER filter. - testInstrumentationRunner -> pl.leancode.patrol.PatrolJUnitRunner (the stock runner would throw ClassCastException in MainActivityTest), plus clearPackageData, testOptions execution ANDROIDX_TEST_ORCHESTRATOR (the runner reads the orchestrator's listTestsForOrchestrator argument to detect the discovery pass -- without it the two-phase protocol never engages), and androidTestUtil androidx.test:orchestrator:1.5.1 (never 1.5.0: it cannot handle whitespace in test names, and ours contain spaces). Proven locally without an emulator: ./gradlew :app:assembleDebugAndroidTest -> BUILD SUCCESSFUL in 29s build/app/intermediates/.../com/bdayadev/oidc/example/MainActivityTest.class build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk Also in this commit: - A CI ratchet. patrol_cli_plus exits 0 on an empty run (its android backend reads only gradle's exit code and prints the "Total: 0" summary without inspecting it), and fixing that upstream needs a publish. The job now greps patrol's own Total: line and fails below 4, so a green Android job that ran nothing is impossible without waiting on a patrol release. Green is not the signal; "Total: N" is. - --verbose on android and windows, matching ios/web/linux. patrol forwards no test stdout without it, which is why the Total line and the [e2e] counts were invisible in the first place. - pubspec key patrol: -> patrol_plus:. The fork's reader does yaml['patrol_plus'] and SILENTLY returns the default config when absent, so the whole block (app_name, package_name, bundle_ids) was being discarded and the uninstall finalizers skipped. Upstream patrol's docs still show the old key, which is where this came from. - Emulator target google_apis_playstore -> google_apis. The Play Store image never booted: 497 "adb: device unauthorized" lines and the AVD step timing out at 900s, where main's default image produced zero. Play Store images ship a locked-down adb that will not accept the runner's key under this job's relocated ANDROID_SDK_HOME. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * ci(android): probe emulator browser + custom-scheme resolution The first run with the discovery fix executed 4 tests (up from 0, the whole point) but completed 0 of 14 conformance logins. "No browser on the image" and "browser present but the redirect is not captured" are different defects with different fixes, and the audit flagged the google_apis browser contents as UNVERIFIED, so this settles it rather than guessing. Three probes before the patrol run: - pm list packages, filtered to chrome/browser/webview/firefox - what resolves an https VIEW+BROWSABLE intent (is there a browser at all) - what resolves com.bdayadev.oidc.example://oauth2redirect -- this one directly tests the Bdaya-Dev#418 fix in the INSTALLED app: it should resolve to com.bdayadev.oidc.OidcRedirectActivity. If it does, the manifest wiring is correct on-device and the gap is browser-side; if it resolves to nothing, the wiring is the problem. Diagnostic only; the ratchet and the test run are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * ci(android): probe the redirect scheme AFTER install, not before The previous probe ran before patrol_plus test, which is what builds and installs the APK -- so 'com.bdayadev.oidc.example://oauth2redirect -> No activity found' was guaranteed regardless of whether the manifest is wired. Mis-ordered on my part; the result proved nothing. The pre-install probes DID settle one thing: google_apis ships Chrome (package:com.android.chrome) and an https VIEW intent resolves to com.android.chrome/...IntentDispatcher. So the emulator has a real browser and 'no browser' is eliminated as the cause of 0/14 logins. Moves the custom-scheme probe after the patrol run and adds two more: is the app installed, and what VIEW/BROWSABLE filters does it actually declare. That is the direct on-device test of the Bdaya-Dev#418 fix -- if the scheme resolves to OidcRedirectActivity the wiring is correct and the remaining failure is browser/flow-side; if it does not, the wiring is the defect. patrol's exit status is captured via PIPESTATUS and deferred to the end of the script rather than aborting under set -e, because the diagnostics matter most when the run failed. The ratchet remains an additional gate: a run can execute 4 tests and still fail one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * ci(android): read the redirect scheme from the built APK, not the device Two probe placements were wrong before this one, so state the reasoning. An adb probe AFTER the run reports 'app NOT installed': patrol uninstalls the app when it finishes, and its uninstall finalizers only began running once the pubspec key was corrected to patrol_plus: -- fixing that key is what enabled them. A probe BEFORE the run is equally useless because patrol is what installs the app in the first place. A local APK is not a substitute either. Dumping the local app-debug.apk showed android:scheme="com.bdayadev.oidc.unset" and I nearly reported the wiring as broken; the artifact was 22 seconds OLDER than the merged manifest beside it and predated the library-default removal. The current merged manifest correctly says com.bdayadev.oidc.example, and the only surviving 'unset' strings in the tree are comments. So: aapt2 dump xmltree over the APK patrol actually built. Install-independent, immune to the uninstall finalizer, and it reads the bytes that were installed rather than anything reconstructed. If OidcRedirectActivity is registered for com.bdayadev.oidc.example there, the Bdaya-Dev#418 wiring is correct in CI and the remaining 0/14 is browser/flow-side. Diagnostic only. The ratchet and the deferred PATROL_RC are unchanged -- confirmed working on the last run: 'Android patrol executed 4 test(s).' passed the floor and the job still failed on patrol's own exit 1, no masking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * ci(android): suppress Chrome first-run before the conformance leg Hypothesis, stated as one. Every other precondition is now individually proven: - Chrome IS on the image: package:com.android.chrome, and an https VIEW intent resolves to com.android.chrome/...IntentDispatcher (pre-install probe). - The APK registers the receiver correctly: aapt2 dump xmltree of the APK patrol actually built shows OidcRedirectActivity, exported, VIEW+DEFAULT+BROWSABLE, android:scheme="com.bdayadev.oidc.example" -- the app's own scheme, so the Bdaya-Dev#418 wiring is right in CI and the earlier local dump showing 'unset' was a stale artifact 22s older than its own merged manifest. - The OP needs no human: linux and macos complete 6 of 14 modules unattended over a loopback redirect. Android still completes 0/14, so the remaining suspect is Chrome never NAVIGATING at all: a first-run device shows the welcome/ToS gate, and a Custom Tab opened behind it never reaches the auth URL, never redirects, and every module times out -- which matches the observed ~34s per module across 473s. /data/local/tmp, not /data/local: the older path is SELinux-blocked. The first token is a placeholder because Chrome parses the file as an argv line and discards element 0. The file is echoed back so the run records whether it was actually written. If 0/14 survives this, the first-run gate is eliminated too and the next suspect is the Custom Tab -> intent-filter handoff itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(ci): fail when an integration coverage file has zero source records The cheapest possible signal for "this platform ran nothing" was sitting in the artifacts the whole time and nobody looked at it. On main's LAST GREEN RUN (29303400030) the android job uploaded android-coverage.info at exactly 0 bytes -- 0 SF records, 0 covered lines -- because it executed zero tests. It was uploaded, downloaded, combined and shipped to Codecov without a single objection. Three layers each declined to notice: - `if-no-files-found: error` on the upload asserts the file EXISTS. A 0-byte file exists. - codecov.yml sets `patch: false` and `project: false`, so every coverage status is disabled and Codecov gates nothing. - the repo's own "[Coverage] Assert Flutter package coverage survived" guard -- written for exactly this class of bug, when a truncation silently dropped Flutter unit coverage -- covers only unit_tests' four packages. It was never extended to the six integration coverages. So the guard pattern already existed and simply had not been pointed at the jobs that needed it. This points it there: before the combine, every *-coverage.info must carry at least one SF record, and the platform is named in the error. An empty lcov is not a coverage regression to debate, it is proof the platform executed nothing. Verified against both real artifacts: main run 29303400030 (0 tests) -> 0 bytes, 0 SF -> guard FIRES run 30140407957 (4 tests) -> 72630 bytes, 132 SF, 1006 covered lines, including packages/oidc_android/lib/oidc_android.dart -> guard passes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * chore: keep session-artifacts out of the repo git add -A swept a session handoff page into the previous commit. It is a local deliverable for this session, not repo content, so it is removed from tracking and the directory is ignored. The file itself stays on disk. * docs: rewrite comments as maintenance context, not incident history The comments added across this change were written as a narrative of how the bug was found: dates, run ids, "this is how Bdaya-Dev#418 shipped", "verified live", first-person asides. That is session history, not information a maintainer reading this code in a year needs, and it violates the repo's own rule that a comment explains why and does not narrate. Rewritten to state the constraint and the reason it exists, dropping the storytelling, the em-dashes, and the issue-number references that only make sense with this session's context. The technical content is unchanged: why the capture path is dual, why the library ships no placeholder default, why the orchestrator is required, why the coverage guard exists. Comments only, no behaviour change. oidc_android native suite still 13/13; workflow yaml re-parsed and the ratchet, deferred exit status and coverage guard all confirmed intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 * fix(example): stop printing bearer tokens into the conformance logs The root logger listener prints every record to stdout and the per-module listener archives them, so `token.toJson()` put the access, id and refresh tokens into a public CI log. Log the token's shape instead — which is what the surrounding assertion actually cares about. Also corrects two docs claims: Auth Tab's result path needs a ComponentActivity host, so the default FlutterActivity always goes through OidcRedirectActivity regardless of browser version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…daya-Dev#421, Bdaya-Dev#422, Bdaya-Dev#423, Bdaya-Dev#424) (Bdaya-Dev#425) * feat(oidc_core): silent-acquisition API at the manager/app boundary (Bdaya-Dev#421, Bdaya-Dev#422, Bdaya-Dev#423, Bdaya-Dev#424) Four additive, non-breaking additions to `OidcUserManagerBase`, all of which land on the same file and are therefore shipped together. Bdaya-Dev#424 — typed failure classification * `OidcException.kind` (nullable `OidcTokenRefreshFailureKind`) surfaces on the THROWING path the classification the library already computed for `OidcTokenRefreshFailedEvent`. * `OidcInteractionRequiredException extends OidcException` marks exactly the failures whose recommended handling is "start an interactive login", so callers stop string-matching `invalid_grant`. Being a subclass, every existing `on OidcException` catch keeps matching. * `OidcException.raw` is the generative constructor a subclass needs to carry an `errorResponse` AND the internal error fields at once (the two existing constructors each force one of them to null). Bdaya-Dev#421 — `getAccessToken({minValidity, forceRefresh})` Returns a token guaranteed valid for at least `minValidity`, refreshing only when it would not be. Concurrent callers join the existing in-flight refresh latch (previously shared only by the two automatic on-expiry handlers), so N parallel API calls perform ONE token exchange — the rotating-refresh-token stampede: rotation invalidates the previous refresh token on every exchange (RFC 9700 §2.2.2 requires rotation or sender-constraining for public clients), so a second concurrent exchange presents an invalidated token and the AS "will revoke the active refresh token" (§4.14.2). The legacy `refreshToken()` is deliberately UNCHANGED: it stays unconditional and un-coalesced for callers that need a raw exchange (e.g. with an `overrideRefreshToken`, which must never be coalesced with a refresh of a different token). Bdaya-Dev#422 — `createDPoPProof` / `cacheDPoPNonce` / `dpopThumbprint` A thin public facade over the already-existing `OidcDPoPManager` primitives, which stays `@protected` so its internals remain encapsulated. Without this, enabling `settings.dpop` sender-constrained the token but left the app unable to prove possession on its own API calls. Bdaya-Dev#423 — `signInSilent({timeout, scopeOverride, extraParameters})` Promotes the `prompt=none` path to public API: refresh_token grant first when one is available (through the same latch), otherwise a hidden-iframe `prompt=none` request. The forced iframe options preserve every other configured platform option. `reAuthorizeUser()` is kept and deprecated. Shared plumbing: `_performAutoRefresh` now takes the refresh `source` (so a caller-initiated refresh is reported as `manual`, not `autoExpiry`) and returns the original error/stackTrace alongside the classification, so a caller that must THROW can preserve the diagnostics the automatic paths deliberately swallow. 33 new unit tests in `test/app_boundary_api_test.dart`. * docs(oidc): lead the access-token guide with getAccessToken, DPoP and signInSilent The guide previously showed `currentUser?.token.accessToken` — a freshness-unaware read — in both the dio interceptor and the http client example, and had no DPoP path at all despite the package implementing RFC 9449 end-to-end. * leads with `await userManager.getAccessToken()` and the `on OidcInteractionRequiredException` handling pattern, with a table of what each situation returns/throws; * documents `signInSilent()` and states the platform matrix honestly (`prompt=none` in a hidden iframe is web-only, and even there depends on a third-party-context cookie that Safari's ITP blocks); * adds the DPoP-protected-resource recipe (`DPoP` + `Authorization: DPoP` header pair, plus the `use_dpop_nonce` retry); * updates the dio/http snippets to await the helper. * fix(oidc_core): close cache-first revalidation race in getAccessToken/signInSilent Review round on Bdaya-Dev#425: getAccessToken()/signInSilent() joined the shared _autoRefreshInFlight latch but not the separate in-flight OidcInitMode.cacheFirst (default) background revalidation, which refreshes via the raw, un-coalesced _refreshToken. Calling either immediately after init() returns (cacheFirst returns before the background pass settles, by design) raced that revalidation and presented the same refresh token in a second concurrent /token exchange (RFC 9700 rotation-reuse hazard). - Add _cacheFirstRevalidationFuture + _joinCacheFirstRevalidationIfInFlight so both entry points await/join the in-flight revalidation and reuse its outcome instead of starting a competing exchange. - Fix _performAutoRefresh returning failureKind: transient even when offline mode had already absorbed the failure, which made getAccessToken/ signInSilent throw instead of returning the cached session while offline. - Remove a now-redundant dpopThumbprint override in login_dpop_flow_test.dart that silently shadowed Bdaya-Dev#422's new base-class getter (annotate_overrides). - Add regression tests for both fixes, mutation-checked against a revert. packages/oidc_core: 731 tests passing (724 before this round). * fix(oidc_core): keep the cache-first revalidation gate self-clearing when init() itself fails Advisory A1 (PR Bdaya-Dev#425 review): `_scheduleBackgroundRevalidation` awaited `initFuture` OUTSIDE its own try/finally. `init()`'s cache-first branch restores the cached user, arms `_cacheFirstRevalidationInFlight` / `_cacheFirstRevalidationFuture`, THEN calls the overridable `attachLifecycleListeners()` — if that throws synchronously, `initFuture` completes with an error, the unguarded await rethrows it before the try/finally, and the finally that clears both fields never runs. Every later getAccessToken()/signInSilent() call then re-joins the same permanently-errored future via `_joinCacheFirstRevalidationIfInFlight` and rethrows the stale init error forever (`ensureInit()` doesn't gate this: `AsyncMemoizer.hasRun` flips true synchronously on `init()` call, independent of success/failure). Worse, `_cacheFirstRevalidationInFlight` also never clears, permanently suppressing the on-expiry auto-refresh gated on it in handleTokenExpiring/handleTokenExpired. Fix: move `await initFuture` inside the try, so the catch-all/finally always runs regardless of whether init() succeeded — matching the existing "revalidation future never throws" guarantee the dartdoc already claimed (now made durably true, and the doc expanded to say why). Regression test: a manager whose attachLifecycleListeners() throws synchronously, seeded with a cache-first-restorable token; asserts getAccessToken() after the failed init() returns the cached token instead of leaking the init error. Mutation-checked (fails without the fix, verbatim _InjectedAttachFailure rethrow; passes with it). * test(oidc_core): close advisory A3 — pin the auto-refresh-disabled consequence of the A1 fix The A1 fix (d9d246c) moved `await initFuture` inside the try in `_scheduleBackgroundRevalidation` so its finally always clears `_cacheFirstRevalidationInFlight`/`_cacheFirstRevalidationFuture`. The shipped regression test only asserted the visible symptom — a later getAccessToken() no longer rethrowing the stale init() error — but that call masks the more severe, silent consequence: `_cacheFirstRevalidationInFlight` also gates handleTokenExpiring/handleTokenExpired directly (an early `if (_cacheFirstRevalidationInFlight) return;` in each, ahead of _autoRefresh), so a skipped finally leaves on-expiry auto-refresh permanently and silently disabled. Add a probe that never calls getAccessToken()/signInSilent(): reuses the existing _ThrowingAttachManager harness (attachLifecycleListeners() throws synchronously, reproducing init() failing after the cache-first gate is armed), waits for the already-scheduled background revalidation to settle, then drives handleTokenExpiring and handleTokenExpired directly off the same token event (back-to-back, sharing the _autoRefresh in-flight latch) and counts real /token exchanges via the mock client. Mutation-checked: reverting the A1 fix (await initFuture back outside the try) makes the new assertion fail with the predicted 0-exchange symptom (Expected: <1> Actual: <0>) — both handlers hit their early return because the gate never clears. Restoring the fix returns it to green. * style(oidc_core): dart format the A3 regression test The `handleTokenExpiredForTest` wrapper was split across two lines; the stable formatter wants it on one. The Format step gates `unit_tests (stable)`, so this failed the job and SKIPPED every subsequent test step (Analyze, VM/Chrome/Firefox suites, Flutter packages, coverage asserts) — the stable matrix produced no signal at all. Formatting only; no behaviour change. `dart format --set-exit-if-changed` now clean, and the file's 2 tests still pass. --------- Co-authored-by: Ahmed Fwela <claude1@digrum.com>
- oidc@3.0.0 - oidc_android@2.0.0 - oidc_core@2.1.0 - oidc_cli@1.0.2 - oidc_default_store@1.1.1 - oidc_desktop@1.0.2 - oidc_darwin@1.1.1 - oidc_linux@1.0.2 - oidc_platform_interface@1.0.2 - oidc_web@1.0.2 - oidc_web_core@1.1.1 - oidc_windows@1.0.2 Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…ting 21 (Bdaya-Dev#427) The getting-started guide told the reader to set `minSdkVersion 21`. Since oidc_android 2.0.0 that instruction fails the build: the plugin depends on androidx.browser:browser:1.10.0, which declares minSdk 23, so a lower value in the consuming app fails the manifest merge. Neither doc stated the requirement anywhere, so someone hitting that failure had nothing to explain it - and the only copy-pasteable snippet in the guide was the one that caused it. Both docs now name the floor, cite the dependency that sets it, and flag it as the one change that can break a build when upgrading from 1.0.1 (floor 21). The flutter_secure_storage step also asked for its own floor, which the 23 above already satisfies; it now says so rather than offering a second, conflicting number. The platform summary still described Android as "Chrome Custom Tabs", which the next section of the same file contradicts - capture has been dual-path since 2.0.0, Auth Tab with a Custom Tabs fallback. Claude-Session: https://claude.ai/code/session_016fkPMs9ug8v8i32BKnYgu2 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
- oidc_android@2.0.1 - oidc@3.0.1 Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…irect Reported on Bdaya-Dev#418 after the redirect fix shipped: login succeeds, but the browser tab stays open and the user has to close it by hand. CustomTabsIntent.launchUrl starts the browser with no FLAG_ACTIVITY_NEW_TASK (browser-1.10.0 sources, CustomTabsIntent.java:828-831 - the only NEW_TASK in that file is in setAlwaysUseBrowserUI, which launchUrl never calls), and OidcPlugin passes the host Activity as that Context. So the Custom Tab lives in the host app's own task: host -> CustomTab -> OidcRedirectActivity OidcRedirectActivity.deliver() then calls finish(), which pops only itself and leaves the user on the browser. The receiver's own doc comment asserted the opposite - that finishing it returns the user to the host - which is true only if the tab were in a separate task, and it is not. Consuming a redirect now re-launches the host with CLEAR_TOP, which pops the Custom Tab off the task with it, plus SINGLE_TOP so the existing host receives onNewIntent rather than being recreated and losing its Flutter engine. Only the intent-filter path needs it; Auth Tab closes its own tab when it returns the Activity Result. The test fails on the assertion that anything is started at all before the fix, and drains the browser Intent that starting the flow queues so it reads the redirect's re-launch rather than the launch. Scope: this pins the Intent's flags. It does not prove the tab disappears on a device - the fallback path still has no end-to-end coverage, which is why the defect reached a user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
Eight options on OidcNativeOptionsAndroid were public, documented API that never reached the browser. OidcPlugin read exactly two keys out of the Pigeon options map - ephemeralBrowsing and flowTimeoutSeconds - and launchCustomTab set a single property on the Intent. Everything else was serialized, sent, and dropped on arrival. How it passed the suite: platform_options_serialization_test.dart asserts each option's JSON shape and round-trip. A shape assertion is green whether or not anything reads the value, so thirteen options round-tripped perfectly and eight did nothing. The same shape appears in Bdaya-Dev#418 (the Android E2E asserted the login call was made, never its result) and in the stranded Custom Tab (asserted the redirect resolved, never that the browser closed). Now applied: showTitle, urlBarHidingEnabled, shareState, closeButtonPosition, colorSchemes (including the nested light/dark/default params), partialCustomTabs (height, resize behaviour, corner radius, background interaction), and rawIntentExtras. useAuthTab is honoured for the first time: `never` pins the Custom Tabs + intent-filter path even on a ComponentActivity host, and `force` now fails loudly with NO_COMPONENT_ACTIVITY instead of silently downgrading. Unset options leave the browser default, so a caller who configures nothing gets the same Intent as before. A non-positive partial height is dropped rather than propagating the IllegalArgumentException that setInitialActivityHeightPx throws. Two guards, because one is not enough. native_option_wiring_test.dart (Dart) fails when an option is declared and never referenced natively, with an explicit exemption map - a dartdoc word-match was tried first and produced a false positive on allowInsecureConnections, which discloses honestly without using the word "Reserved". OidcPluginOptionsTest (Kotlin) asserts the extras actually present on the launched Intent; reverting the wiring turns 7 of its 10 red, and the 3 that stay green are the two absence assertions plus ephemeralBrowsing, which already worked. BREAKING CHANGE: options that previously had no effect now take effect. An app passing colorSchemes, shareState, showTitle, urlBarHidingEnabled, closeButtonPosition, partialCustomTabs or rawIntentExtras will see the browser honour them for the first time. `useAuthTab: force` on a plain FlutterActivity now fails with NO_COMPONENT_ACTIVITY rather than silently opening a Custom Tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
A cross-platform sweep pointed the same check at OidcPlugin.swift and reported navigationMode, successfulPageResponse, methodMismatchResponse and notFoundResponse as inert. They are implemented in oidc_darwin.dart, with their own loopback test file - the check was reading the wrong layer. Android has no Dart-side option handling, so one file is sufficient there, but the guard never said so. It does now, before someone ports it and inherits the false positive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
The README states the package is tested "against multiple conformance profiles", and the suite hardcoded one plan id. Only Basic RP has ever run, so every other claimed profile was asserted in docs and verified nowhere - the same claim-versus-effect gap that produced the inert Android options, one level up at the spec surface. Config RP is the first one added because it tests OpenID Connect Discovery, which the README lists under implemented specs. Plan ids verified against openid.net and panva/openid-client-certification-suite, which runs basic, implicit, hybrid and config; the three logout profiles the README also implies (RP-Initiated, Front-Channel, Session Management) have no published plan id beyond oidcc-client-test-session-management and are deliberately not guessed at. Each plan is its own test case rather than a loop inside the runner, so a failure names the profile that broke and one profile's outage cannot mask another's. This is the failing-test step: whether Config RP passes is unknown, because the conformance suite needs OIDC_CONFORMANCE_TOKEN and can only run in CI. If it goes red, that is the point - the gap it finds gets fixed next, test-first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…rmatting The Config RP case fails at plan creation with HTTP 400, and Dio's message carries only the status code. The conformance suite does say which variant dimension it rejected, in the response body, but the bare rethrow discarded it, so the failure named neither the plan nor the offending key and the only way to learn the reason was to re-run CI. Catch DioException around the plan POST and rethrow with the plan name, the status, the request path and the server's response body. The variant is now per-plan (clientRegistration, requestType) rather than hardcoded to static_client. That was the latent assumption behind the 400: the dimensions a plan accepts are not uniform, and one plan rejects what another requires. Also correct the dartdoc. It listed four plan ids under "Verified", which conflated "transcribed from a published source" with "confirmed to run here". Only the basic plan is confirmed; the config plan is now known to reject the variant this harness sends. `dart format` on app_test.dart and native_option_wiring_test.dart, which failed the stable unit_tests job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
The Config RP plan failed at creation with HTTP 400. The reason, once the harness surfaced the suite's response body: TestModule 'oidcc-client-test-discovery-openid-config' requires a value for variant 'client_auth_type' Not a rejected client_registration, as first assumed, but a missing third variant dimension. The Basic plan resolves client_auth_type to client_secret_basic on its own, which is why omitting it went unnoticed there; the config plan's discovery module requires it stated outright and refuses the whole plan without it, before any module runs. Pass it through the existing extraVariant hook, and set client_secret_basic on the Config RP case in both harnesses. Basic RP is left untouched: it is green, and its default resolves correctly. Also correct the dartdoc, which recorded the earlier wrong guess that the plan rejects static_client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…ired variant
The Config RP variant bug could only be found by a CI round trip against a
live third-party server: prepareTestPlanRequest built a request for any plan
out of whatever dimensions it was handed, and the suite answered HTTP 400
thirty minutes later.
Record per-plan required dimensions and throw ArgumentError naming the
missing one, so the mistake surfaces offline and immediately.
Written test-first: the guard test builds the config plan without
client_auth_type and asserts it throws. Confirmed red against the unfixed
helper, which returned the exact request CI had rejected:
/api/plan?planName=oidcc-client-config-certification-test-plan
&variant={"request_type":"plain_http_request",
"client_registration":"static_client"}
A third case asserts the basic plan still builds without the dimension, so
the guard cannot be over-applied to the one plan that is certified and green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
Config RP passed on macos/ios/android and hung on linux/windows. The difference was not the library: it was that flowTimeoutSeconds was set for the first three and left null for the desktop pair. The run stopped at oidcc-client-test-discovery-issuer-mismatch, whose redirect never arrives, and with no timeout the loopback listener waited until GitHub Actions killed the job ten minutes later and reaped a pile of orphan chrome processes. The behaviour is documented on the option itself: "otherwise loginAuthorizationCodeFlow() and logout hang indefinitely and leak the bound loopback socket". Set 30s for linux and windows, matching the other three. Written test-first, asserting configuration rather than behaviour on purpose: a hang produces no assertion, no message and no stack, so the failure it would otherwise surface as is a dead job. The test also bounds the timeout at 60s, since a per-module timeout long enough to eat the job budget stops the remaining modules from ever reporting. Scope: this fixes linux and windows. Web fails separately and is NOT addressed here -- it has no flowTimeoutSeconds, using popup navigation and hiddenIframeTimeout instead, so it needs its own diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
…owTimeoutSeconds The popup/newPage await had no upper bound. `hiddenIframeTimeout` covers only the silent-renew iframe, so every other platform could bound an abandoned login and web could not. The sole existing escape was the window-closed poller, and it disarms itself: when COOP severs the WindowProxy it cannot tell a severed handle from a closed window, so it cancels rather than risk a false positive (`canDetectPreparedWindowClosure`). After that nothing can complete the future. A user who walks away from the login, or a browser that applies COOP, leaves a pending future for the lifetime of the tab. Add `OidcPlatformSpecificOptions_Web.flowTimeoutSeconds`, mirroring the Android, Apple and desktop options. Null default, so existing behaviour is unchanged and this is not breaking. Also move the teardown into a `finally`. It previously ran only on success, so an aborted flow left the auth window open behind the app. Proven by negative control: with the timeout reverted the new browser test does not fail, it HANGS, and dies on package:test's own 30s limit -- TimeoutException after 0:00:30.000000: Test timed out after 30 seconds With the fix it passes in about a second. That is the failure mode being removed: a hang reports no assertion, no message and no stack. Found by running the Config RP conformance plan, which hung on web for the same reason it hung on linux/windows. oidc_web_core 55 (chrome), oidc_core 733, example 6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAF3ZttjZMqjix8U7eX3h1
Under bash -e, `vpid="$(cat missing-file)"` takes cat's exit status and ended the step (run 37440456274: every test passed, the iOS job failed, and no diagnostics were uploaded). Dry-run of the step under bash -e with xcrun stubbed now exits 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Resolves the tests.yaml conflict between this branch's iOS 2-way sharding and Bdaya-Dev#469's new "Start/Stop simulator log capture" + "Upload simulator log" steps: both are kept, and the upload artifact name is made per-shard (ios-${{ matrix.shard }}-simulator-log) since actions/upload-artifact requires unique names within one run and this job now has two parallel shards. No other files conflicted (Bdaya-Dev#491's conformance-log-since-race fix and Bdaya-Dev#469's harness/test changes landed in integration_test/ and test/, none of which this branch had touched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
…rmance ci: shard native conformance jobs and trim Apple setup
…er-warmup Rebuilt on main's 2-shard iOS job. The harness (packages/oidc/example) is taken from main as-is, so the earlier probes, controls and the module rerun leave this branch's diff against main. What stays: * .github/scripts/ios_ci_metrics.py, plus cheap default diagnostics: a 3s ps/vm_stat host sampler while the tests run. The app's lines and runningboardd's WebContent launch lines are read back from the simulator's persisted log afterwards. This replaces main's debug-level live log stream, which itself cost 40-60% CPU on a starved host. Heavy diagnostics (live streams, screen recording) are opt-in via workflow_dispatch. * IOS_REDUCE_LOAD (default on): Spotlight indexing off, other simulators shut down, smallest iPhone preferred. * ios_runner dispatch input, to measure macos-26 (3 vCPU / 7 GB arm64) against macos-26-intel (4 vCPU / 14 GB x64, same Xcode 26.6 and iOS 26.4 runtime). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Booting alongside the Flutter install slowed that install 3-4x on the 3-vCPU arm64 runner. The Flutter setup script took 65s with a synchronous boot and 178-273s with a concurrent one; cache-pub's hashFiles took 0.5s vs 27-125s. That is a net loss of 80-240s per iOS leg after Bdaya-Dev#492. The boot now overlaps only flutter precache and the Patrol CLI activation, which moves ahead of the wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
… caching
unit_tests was one job per sdk channel running every step sequentially
(format -> analyze -> VM -> Chrome -> Firefox -> jose Chrome -> jose
Firefox -> oidc_web_core coverage -> oidc_web Chrome -> Flutter ->
coverage combine), making it the critical path of the whole workflow on
main pushes (~41m stable / ~35m beta on run 37423292012). Split into
unit_core/unit_chrome/unit_firefox/unit_jose_chrome/unit_jose_firefox
(sharded 4 ways)/unit_webcore_coverage/unit_oidc_web_chrome/unit_flutter/
unit_tests_coverage so the independent suites run concurrently on the
same ubuntu-latest pool instead of back-to-back. Coverage from the
split jobs is flattened and re-merged by unit_tests_coverage into the
same "package-coverage" artifact the old job produced, so
upload-coverage needed no changes to its own steps (only its needs/if
to account for the new job graph, tolerating the PR-skipped jobs).
Also fixes a real CI failure mode: subosito/flutter-action's `cache:
true` silently also enables its own internal pub-cache step, whose key
unconditionally runs an unscoped `hashFiles('**/pubspec.lock')` -
observed failing outright ("couldn't finish within 120 seconds") on a
macOS leg, and degrading silently to an empty hash segment on Linux
(visible as dead `flutter-linux-stable-x64-<hash>-` cache entries).
Disable that internal step everywhere it's enabled (flutter_base -
shared by every tests.yaml job via integration_test_base - plus
dart_package.yaml/flutter_package.yaml/deploy_firebase.yaml/
firebase_preview.yaml/pana.yaml) and replace it in flutter_base with an
explicit, narrowly-scoped pub-cache step (packages/**/pubspec.yaml,
not an unscoped repo-root **) covering both ~/.pub-cache and the
Windows pub-cache path. Deleted the two dead-format cache entries to
free headroom under the repo's 10GB cache cap.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
… suite Re-applied onto main's harness (after Bdaya-Dev#492), unchanged in behaviour from 3903937. Load reduction did not make the iOS simulator's browser stalls go away: they stayed intermittent across every runner configuration measured. When, and only when, the suite's own log proves it received NO authorization request for an instance, the harness stops that instance and runs the module once more on a fresh one. The suite observed nothing there and judged nothing, so no verdict can be hidden. It never reruns when any authorization request arrived (every negative module), never reruns a rerun, and never reruns on an unreadable log. The rerun is printed to the job log and carried into the module's verdict line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
Run 37464828887 caught a real bug: unit_tests_coverage's "Assert Flutter
package coverage survived" guard failed because combine_coverage,
given the flattened coverage/flat-merged/<pkg>.info files directly,
resolves each file's SF: entries relative to the directory the file is
found in whenever those entries are already package-relative (not
absolute) - which is exactly how `flutter test --coverage` writes them
("SF:lib/facade.dart", no "packages/<pkg>/" prefix), unlike the VM/
format_coverage-generated files, whose SF: entries are absolute and
therefore survived the flattening. Flat, the four Flutter packages'
guard matched 0 records each.
Fix: rebuild packages/<pkg>/coverage/lcov.info from the flattened
artifacts before running combine_coverage, so it sees the same
directory layout the old single job always had.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126i8ymDVXbifp6LrbCiyQL
…help Measured over 2+ iOS runs each, none of these made the simulator browser stalls (Bdaya-Dev#469) go away: * Spotlight off, other simulators shut down, smallest iPhone. Logins >30s per run: 1 and 4 without, 0 and 1 with (single job). In the sharded layout: 0/0 without, 2/1 with. * macos-26-intel (4 vCPU / 14 GB): Set up Environment took 9.5-15 min, and both shards hit the 60-minute timeout in their tests. Both are removed. What stays from the experiment is the cheap default diagnostics, heavy diagnostics as a dispatch input, and the boot ordering. The flaky stall itself is handled by the harness's evidence-gated single rerun. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…tom SF Independent review on PR Bdaya-Dev#494 (run 37476666335) caught a real bug: the "Restore package-relative coverage layout" step copied coverage/flat-merged/<pkg>.info to packages/<pkg>/coverage/lcov.info but left the original flat files in place, so combine_coverage's recursive scan ingested both. The flat copies resolve to bogus "/coverage/<relative-path>" phantom SF: entries once they're no longer sitting in a per-package directory. That run's package-coverage artifact had 254 SF: lines for 139 unique files (115 duplicates) and 11 phantom /coverage/... paths, against a clean 132/132 on the baseline main run (37423292012). Fix: `mv` instead of `cp`, plus an explicit `rm -rf coverage/flat-merged` after the loop, so the stale flat files can never be re-scanned. Also adds a structural guard ("[Coverage] Assert no duplicate or phantom SF entries") right after the combine step: fails the job if any SF: path appears more than once, or if any SF: path doesn't correspond to a real file in the checkout. This catches this failure mode (and anything similar) directly, instead of relying only on the package-specific guards, which passed on the buggy run because the correct entries were also present alongside the duplicates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…ched Review of Bdaya-Dev#495 (F1): the gate read a request the suite REFUSED as one that never arrived. The suite's dispatcher logs "Incoming HTTP request to <path>" before the module runs (TestDispatcher). The "Authorization endpoint" block is only written once the module moves to RUNNING. That throws "Illegal test state change" for a FINISHED/INTERRUPTED instance (AbstractTestModule), and OIDCCClientTestDiscoveryIssuerMismatch throws before the block. So a refused request left no block, and the gate would have rerun past a real verdict. * The gate now requires the instance to still be WAITING, read from GET api/info/{id} before anything is discarded. That is the stall's signature. An instance with a verdict is never rerun, whatever its log says. The log is only read for a WAITING instance. * An "Incoming HTTP request to …/authorize" line counts as arrived, as well as the Authorization endpoint block. * The RERUN note names the discarded instance's status and result. * Unit tests cover the refused-request log shape, FINISHED/FAILED, FINISHED/PASSED and INTERRUPTED/FAILED first attempts, an unknown status, query strings, and other endpoints. Mutation-checked: dropping the WAITING condition or the arrival-line match each fails a test. * The doc comments (api.dart, tests.yaml) now state the real conditions, and say the gate applies on every platform (A1). Also from the review: * F2: the job comment no longer says the boot overlaps Set up Environment. * A2: CONFORMANCE_FORCE_STALL_MODULE (workflow_dispatch input ios_force_stall_module, empty by default) makes the first attempt of one module never open its browser, once per process, to prove the rerun path end to end in CI. * A3: ios_ci_metrics.py closes a login on the RERUN line too, so the discarded attempt's stall still counts. * A4: the collect step tolerates a missing pids file. A dry run under bash -eo pipefail with xcrun stubbed and no pids file exits 0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci: run unit test suites as parallel jobs and fix pub caching
…er-warmup No conflicts. upload-coverage's needs: list (main's split unit_* jobs plus android/ios/web/macos/linux/windows) is unchanged. Its "*-integration-coverage" download pattern still picks up the iOS legs' ios-<shard>-integration-coverage artifacts and not the new ios-<shard>-diagnostics ones. actionlint is clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
Run 37486189186's unit_core (beta) job hung 85+ minutes in "Test Dart packages [VM]" with no bound at all (GitHub's default job timeout is 360 minutes) until a human cancelled it. Every package had already printed its own "N tests passed" summary (oidc_core's, the last one, at the 4-minute mark); x509_plus had hit "Connection reset by peer" loading one test file via its VM-service websocket mid-run (73 passed, 1 failed) shortly before. melos's exec wrapper never returned afterward, and job cancellation found an orphaned dart:test.dart- process at cleanup. - Adds job-level timeout-minutes to every unit_* job and unit_tests_coverage that didn't already have one (unit_core, unit_chrome, unit_firefox, unit_oidc_web_chrome, unit_flutter, unit_tests_coverage), sized from measured durations across several runs with a 2-5x margin. unit_jose_chrome/unit_jose_firefox/ unit_webcore_coverage already had sensible job-level bounds (60/60/ 20m) and are unchanged. - Wraps unit_core's VM step in `timeout --foreground -k 10 600` (matching the pattern unit_chrome/unit_firefox's PR-scope retry loops already use), the actual fix for the hang: GNU timeout signals the whole process GROUP (TERM then KILL), which is what's needed to reap an orphaned child process a hung `melos run test:vm` left behind, rather than relying on GitHub's own job-level kill as the only way out. Investigated whether the CI job split (PR Bdaya-Dev#494) changed melos exec's concurrency for this step: it didn't -- melos defaults to Platform.numberOfProcessors regardless, and that's unchanged by the split (same runner, same package set, same formula, before and after). Checked several historical main-push runs (pre- and post-split): the "Connection reset by peer" / hang signature did not reproduce in any of them, including the very next main push after the split landed. This looks like a rare, intermittent VM-service coverage-collection race, not something the split caused. As defense-in-depth (not a proven root-cause fix, per the above), pubspec.yaml's test:vm script now caps melos exec concurrency at 2 for this step specifically (fewer concurrent VM-service websockets == lower odds of hitting whatever triggers the reset), using melos's map-form `exec: {concurrency, command}` schema (confirmed supported by the melos 8.9.0 this repo's CI actually activates). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci(ios): rerun a module once when the browser never reached the suite; boot after setup
- oidc@5.0.2
The first validation run (37502004205) failed both unit_core legs
immediately with:
MissingScriptCommandException: The script test:vm failed to execute.
You must specify a script to run...
from melos 8.9.0 (the version this repo's CI actually activates),
despite the previous commit's `exec: {concurrency, command}` map form
matching melos's own source for that exact tag. Not trusted further.
Replaced with the same pattern the existing `pana` script already uses
successfully in this file (`run: melos exec -c 1 "pana"`): a `run:`
script that invokes `melos exec -c 2` directly, with --no-flutter
--dir-exists=test --ignore="*web*" replicating the dropped
packageFilters exactly as melos exec's own CLI filter flags (confirmed
from melos's actual command_runner/exec.dart + base.dart source for
the 8.9.0 tag).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
chore(release): Publish packages
Main run 37502027906 (linux implicit, attempt 1) failed Implicit RP on
oidcc-client-test. The authorization request reached the suite 30.07s
after the login started, the loopback listener gave up at 30s, and the
module was left WAITING. The gated rerun correctly stayed out of it,
because the request did arrive. The same module's next attempt took
1.9s.
It is the plan's FIRST login that is slow: it pays for the CI runner's
system browser cold-starting. Login durations across 6 runs' legs
("Starting login" -> "[e2e] ... authResult"), first login of each plan
vs every other login:
linux first n=46 max 30.4s (then 24.6, 12.7, 12.3, 12.1) others max 7.7s (n=884)
windows first n=45 max 15.2s (then 13.3, 10.2, 8.7) others max 7.9s (n=885)
macos first n=20 max 14.0s others max 2.4s (n=445)
Android logs carry no Dart output, so there is no data there; it stays
at 30s, and so does macOS.
linux/windows now get desktopConformanceFlowTimeoutSeconds = 60, about
twice the worst first login seen. A pinning test ties it to
observedDesktopFirstLoginSeconds (30.4s), with a 1.5x margin; setting it
back to 30 fails the test. It costs nothing on a healthy flow, which
ends at the redirect, and stays inside the existing <= 60s per-module
budget check.
No browser warm-up: the cold start happens once per plan either way.
The timeout only has to tolerate it, and adding a warm-up would cost a
browser launch per plan on every platform.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
--foreground stops GNU timeout from timing out COMMAND's children, so it could not reach an orphaned `dart test`. Drop it so the TERM/KILL goes to melos's process group, and correct the comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
ci: bound unit test jobs and the VM test step so a hung test can't stall a run
…eout test(example): give a desktop plan's first login time for a cold browser
subosito/flutter-action's `cache: true` wraps the Flutter SDK download in its own internal actions/cache step, keyed on outputs (CACHE-PATH/CACHE-KEY) produced by an earlier step in the same composite action. That internal cache step's POST (save) phase is a long-standing, still-open upstream bug on macOS and Windows runners (subosito/flutter-action#350): it cannot re-resolve the `${{ steps.flutter-action.outputs.CACHE-PATH }}` expression its own `path` input depends on, logs "Input required and not supplied: path", and silently no-ops -- so the SDK is never persisted and every run on those OSes re-downloads+extracts it from scratch (confirmed live on this repo's own runs, e.g. run 37523659563: the windows/macos/ios "Cache Flutter" POST step prints exactly that warning). Linux jobs going through the same flutter_base action look fine only because they already have a warm cache from before and never reach the broken save path. Fix: resolve the (channel/version/arch/hash)-based cache coordinates via a `dry-run: true` flutter-action invocation (its "Set action inputs" step always runs in print-only mode regardless and exposes CACHE-PATH/ CACHE-KEY as composite outputs), cache that path ourselves with a plain, non-nested actions/cache step (same pattern as the pub-cache step just below it, whose POST already resolves correctly cross-OS), then run flutter-action again with `cache: false` to actually install -- it skips the download when our step already restored the SDK into that path. Applied to flutter_base (used by every tests.yaml job) and every other workflow invoking flutter-action with `cache: true` (dart_package/flutter_package/pana/firebase_preview/deploy_firebase.yaml). Also drops firebase_preview.yaml/deploy_firebase.yaml's custom `cache-key` that omitted `:version:`: it produced a cache entry (`flutter-linux-stable-x64-<hash>`) duplicating the bytes of the one flutter_base already populates for the same channel/version/arch/hash. Using flutter-action's own default key (via the dry-run step) unifies with that entry instead of duplicating it, which matters under this repo's 10GB total cache cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
Live verification (run 37532075225, dispatched on this branch) showed
the first fix attempt wasn't enough: routing the SDK cache through our
own, non-nested actions/cache step still hit the exact same "Input
required and not supplied: path" warning on macOS/Windows, because its
`with: path/key` referenced `${{ steps.flutter_sdk_info.outputs.* }}`
directly. The bug isn't specific to flutter-action's own internal cache
step or to composite-action nesting -- it's that a `with:` expression
reading `steps.<id>.outputs.*` cannot be re-resolved during that
action's own POST (save) phase on macOS/Windows runners, regardless of
whose step it is. The existing pub-cache step never hit this because its
key/path only reference flat, non-output values (inputs/runner.os/
hashFiles), which DO replay correctly in POST.
Fix: copy the two outputs into plain env vars via $GITHUB_ENV right
after resolving them, and have the cache step reference `env.*` instead
of `steps.*.outputs.*`. Applied to flutter_base and every other workflow
carrying the same pattern from the previous commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016g5xVp7yzwhi7f9tLwcNBj
…saves Two cache-budget fixes requested on top of the Flutter SDK cache fix: 1. android job (tests.yaml): gradle/actions/setup-gradle's own cache-read-only default (read-only off the default branch, read-write on it) doesn't account for this job being sharded 4 ways. On every main push all 4 shards run concurrently and all get write access, independently racing to save the same (not shard-scoped) gradle-dependencies-v2/gradle-transforms-v2 entries -- confirmed live via the cache listing: two non-identical sibling entries each (1.13GB+0.67GB, 1.31GB+0.47GB) instead of one shared entry. Layered a single-writer rule on top (`matrix.shard != 'basic'` forces cache-read-only even on main) while leaving the upstream per-branch default untouched for every other case. Deleted the two confirmed duplicate entries to free the space immediately. 2. Flutter SDK cache (flutter_base + the other flutter-action callers): split the combined actions/cache step into actions/cache/restore (always, every ref) and actions/cache/save (only `github.event_name == 'push' && github.ref == 'refs/heads/main'`). PR and workflow_dispatch runs now restore whatever main has already populated but never write their own branch-scoped copy of this multi-GB-per-OS entry. As a side benefit, the split actions have no POST hook at all, so this no longer depends on the env-var replay fix working correctly in a deferred phase -- the save simply runs, or doesn't, synchronously at this point in the job. Steady-state cache estimate (current measured total via `gh api repos/Bdaya-Dev/oidc/actions/cache/usage`, after the dedup above, plus the macOS/Windows SDK entries this fix adds once a real main push populates them): ~6.40GB existing + 2.22GB (macOS SDK) + 1.93GB (Windows SDK) =~ 10.26GB -- still slightly over the 10GB cap by ~2.5%, driven almost entirely by the two new, unavoidable multi-GB SDK entries this fix exists to create. GitHub's eviction policy degrades gracefully (LRU eviction, not a failure) if exceeded; flagged for the coordinator rather than silently claimed as safely under budget. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
actions/cache/save has no memory of a separate restore step, so every main push re-uploaded the ~2 GB SDK, once per shard. Give each restore step an id and gate the save on cache-hit != 'true'. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ci: make the Flutter SDK cache actually save on macOS/Windows, and dedupe Gradle
pedrosimoespt
self-requested a review
November 3, 2026 14:58
pedrosimoespt
approved these changes
Nov 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
mainup to date with upstreamBdaya-Dev/oidcmain(fce87b98), 946 commits.main.Why
itowin now pins upstream release tags from this fork (
oidc-v4.0.0,oidc_default_store-v1.1.2, IT-6302 — upgrade oidc and flutter_secure_storage). Our only patch (oidc_default_store-v0.2.0+12.ptech.1, SharedPreferencesAsync) has been part of upstream sinceoidc_default_store1.1.2, so the fork no longer needs to differ from upstream.Merge
Use Create a merge commit, not squash or rebase, so upstream's commits keep their hashes and the fork shows 0 commits behind.
Not affected
oidc-v0.9.0+1andoidc_default_store-v0.2.0+12.ptech.1stay for the releases that still pin them.use-shared-preferences-async-on-default-storestays until no release uses it.