Skip to content

Claude/wonderful davinci d rqvy#69

Merged
mucsi96 merged 4 commits into
mainfrom
claude/wonderful-davinci-dRQVY
Jun 4, 2026
Merged

Claude/wonderful davinci d rqvy#69
mucsi96 merged 4 commits into
mainfrom
claude/wonderful-davinci-dRQVY

Conversation

@mucsi96

@mucsi96 mucsi96 commented Jun 4, 2026

Copy link
Copy Markdown
Owner

No description provided.

claude added 2 commits June 3, 2026 19:41
inspectOidcStorage proved the refresh token loss is a library-side
resetAuthDataInStore() (blob survives, authnResult emptied), not iOS
eviction. The reset is triggered by a failing silent renew - which runs
independently of AuthService.refresh() and was therefore unlogged.

Subscribe to PublicEventsService as an app initializer (registered before
withAppInitializerAuthCheck so it catches the bootstrap renew) and log the
renew lifecycle: validation result and the Azure error/error_description
(AADSTS code) behind a failure. Structure only, no token values.

https://claude.ai/code/session_01FncAPUn5nj7aeECLoa1bnD
Throw out angular-auth-oidc-client (too much hidden behavior: any failed
refresh silently wiped the stored session and stopped the background renew
timer, plus an uncontrolled timer-based renew loop). Move to oidc-client-ts
driven through UserManager with every automatic behavior disabled
(automaticSilentRenew/monitorSession/loadUserInfo off), so every redirect,
token exchange, refresh and storage write is our own explicit, logged code.

- auth.config.ts: build a single UserManager from the runtime
  EnvironmentConfig (Azure AD vs mock branch unchanged); keep user + PKCE
  state in localStorage so they survive the iOS PWA redirect.
- auth.service.ts: same public surface (isAuthenticated, userData, init,
  login, logout, ensureAuthenticated, refresh) backed by UserManager; a
  failed signinSilent no longer wipes the session. Port the storage
  introspection and structured [auth] logging to the new user-blob shape.
- token.interceptor.ts: attach the bearer token ourselves (replaces the
  library's authInterceptor); authRetryInterceptor 401 recovery unchanged.
- user-profile.service.ts: read id_token claims from the new userData shape.
- faro.ts: explicit flushFaro() before redirects to keep pre-redirect logs.

Client builds clean; E2E mock-OIDC contract (authority, scopes, /api bearer)
is unchanged so the Playwright suite needs no changes.

https://claude.ai/code/session_01FncAPUn5nj7aeECLoa1bnD
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown

Code Review

Overview

This PR migrates the Angular client from angular-auth-oidc-client to the framework-agnostic oidc-client-ts. The approach is sound: every automatic behaviour (automaticSilentRenew, monitorSession, loadUserInfo) is explicitly disabled so the app fully owns the refresh lifecycle. The iOS PWA edge cases (timer freezing, storage eviction) that drove the previous library's workarounds are now handled in application code that is easier to reason about.


Code Quality & Style

Positive

  • Clean USER_MANAGER injection token pattern; Angular DI is used correctly throughout.
  • AuthService is now properly async (init(): Promise<void>) and the four private helpers (registerEventLogging, loadInitialUser, runColdStartRefresh, installForegroundRefresh) give the class a clear, readable structure.
  • Using Angular signal<User | null>(null) for user state is idiomatic and aligns with AGENTS.md.
  • The new tokenInterceptor is minimal and correct — the regex /\/api(\/|$)/ prevents false matches on e.g. /apikeys.
  • flushFaro() is a clean extraction of pre-redirect telemetry flushing.
  • user-profile.service.ts simplification is correct: oidc-client-ts surfaces claims as a flat Profile object, no more nested userData.userData.

Issues

  1. Multi-line comment blocks — AGENTS.md says "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." auth.config.ts has two block comments that span 10+ lines (baseSettings and buildAzureAdSettings/buildMockSettings). The why is non-obvious (iOS PWA, PKCE state, no refresh token on mock), so some commentary is warranted, but consider condensing each block to 1–2 sentences or moving the rationale into commit/PR description.

  2. as UserManagerSettings casts — both buildAzureAdSettings and buildMockSettings end with } as UserManagerSettings. This is because baseSettings() returns Partial<UserManagerSettings>, and TypeScript can't prove the spread satisfies the full type. One cleaner alternative is to make baseSettings return Pick<UserManagerSettings, keyof ReturnType<typeof baseSettings>> (exact keys) and let the callers complete the required fields. Or simply accept the cast; it's low risk here.

  3. Redundant this.user.set() callsregisterEventLogging() registers an addUserLoaded handler that calls this.user.set(user). The UserLoaded event fires when signinSilent() or signinRedirectCallback() completes — the same moment that the explicit this.user.set(user) calls in refresh() (via tap) and loadInitialUser() run. Both set the signal to the same User object, so there is no bug, but the redundancy can be confusing. Consider either:

    • Removing the tap in refresh() and trusting the event, or
    • Removing the addUserLoaded handler and relying on the explicit sets (the current pattern, just without the duplication).

Potential Bugs

  1. init() is async — caller must await it — if AppComponent or an APP_INITIALIZER token calls authService.init() without await, the app could render before loadInitialUser() finishes and this.user will be null on first render. The diff doesn't show the call-site; worth verifying this is awaited (or that the APP_INITIALIZER provider returns the Promise).

  2. ensureAuthenticated() as a side-effectful Observable — the switchMap block sets this.user.set(user) as a side effect inside the Observable chain:

    switchMap((user) => {
      this.user.set(user);   // <-- side effect
      ...
    })

    If the route guard is called while a refresh is in flight (it will wait via inFlightRefresh$), this re-reads and overwrites the signal. Since inFlightRefresh$ settles first and refresh() has already set the user, this is a no-op in the normal path — but it's fragile. A comment explaining why the re-read is necessary here would help.

  3. runForegroundRefresh() unguarded Promise — the method uses this.userManager.getUser().then(...). The .then() callback subscribes to this.refresh('foreground') with takeUntilDestroyed, but the getUser() Promise itself isn't cancelled if the component is destroyed first. In practice getUser() is a synchronous localStorage read wrapped in a resolved Promise, so this won't cause real problems — but it's inconsistent with the RxJS-first style of the rest of the service. Could use from(this.userManager.getUser()).pipe(takeUntilDestroyed(...), ...) for consistency.


Security Considerations

  • localStorage for PKCE stateStore — storing the code_verifier in localStorage (rather than sessionStorage) is a deliberate trade-off for the iOS standalone PWA use-case. The comment explains the reasoning and it is correct. Worth noting this decision explicitly in the PR description for future reviewers.
  • URL cleaned after callbackhistory.replaceState(history.state, '', url.origin + url.pathname) correctly strips ?code=&state= from the address bar after signinRedirectCallback(). Good.
  • Mock logout fallback — the .catch on signoutRedirect that falls back to removeUser() + hard reload is correct; the mock server has no end_session_endpoint.
  • Token in getAccessToken() — returns access_token directly for the Bearer interceptor. The token may already be expired; the authRetryInterceptor handles this. The design is intentional and documented in the interceptor comment.

Test Coverage

No new tests are included. This is a significant auth library migration that changes:

  • The redirect callback flow
  • Silent refresh behaviour
  • Token attachment
  • Logout flow

The existing Playwright E2E tests presumably cover the login/logout happy path, but they would not catch regressions in the cold-start refresh, foreground-refresh, or 401-retry paths. Consider adding (or noting that you've manually verified) coverage for:

  • Cold start with a stale access token (has refresh token → silent renew succeeds)
  • Foreground refresh after backgrounding the PWA
  • 401 retry with a mid-request token expiry
  • Mock provider logout (the removeUser fallback path)

Summary

The migration is architecturally cleaner than the old library integration. The explicit control over every automatic behaviour, the clear separation into single-responsibility helpers, and the improved signal-based state are all improvements. The main things to address before merging:

  • Verify init() is awaited at the call site.
  • Resolve the redundant user.set() (event handler vs. tap/explicit set).
  • Consider consolidating runForegroundRefresh to use from() + RxJS pipeline instead of a bare Promise .then().

…imer

Keep Faro's default 250ms batching (drop the sendTimeout:0 override). Faro
2.x has no public flush, so wrap each transport's send to track the network
promise it returns, and make flushFaro() push a marker, let the batch
interval dispatch the buffered logs, then await every tracked send (bounded
by a timeout so a redirect never hangs). login()/logout() now await
flushFaro() before signinRedirect/signoutRedirect.

https://claude.ai/code/session_01FncAPUn5nj7aeECLoa1bnD
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown

Code Review

Overview

This PR migrates the Angular client's OIDC authentication from angular-auth-oidc-client (an Angular-specific wrapper) to oidc-client-ts (a framework-agnostic library). The migration gives the app full control over the auth flow, disables all automatic library behaviors, and fixes a known iOS PWA PKCE-state-loss bug by using localStorage for both the user session and OIDC transient state.


What's Good

  • Architecture: Moving to oidc-client-ts with every automatic behavior explicitly turned OFF (automaticSilentRenew, monitorSession, loadUserInfo) is the right call — no hidden magic, no more "IncorrectNonce" surprises from the previous library.
  • Signal-based state: user = signal<User | null>(null) is idiomatic Angular 21 and replaces several observable subscriptions cleanly.
  • iOS PWA fix: Setting both userStore and stateStore to localStorage correctly solves the PKCE code_verifier loss on iOS standalone PWA redirects.
  • signinSilent() on failure: Leaving the stored user intact on failure (rather than wiping it) is a correct and meaningful improvement — the next 401 retry or foreground refresh can reuse the refresh token.
  • flushFaro(): Clever and necessary. Tracking in-flight transport sends and draining them before a full-page redirect is the right solution; the old batching: { sendTimeout: 0 } hack is gone.
  • inspectOidcStorage simplification: The oidc.user: prefix lookup is correct and much cleaner than the old slot-scanning heuristic.
  • token.interceptor.ts: Thin, clear replacement for the library's authInterceptor().
  • init() decomposition: Breaking the monolithic runColdStart() into registerEventLogging(), loadInitialUser(), installForegroundRefresh(), runColdStartRefresh() makes each responsibility obvious.

Issues

1. Dead injection — NotificationsService is never used (auth.service.ts)

showError() was removed but notifications was not:

private readonly notifications = inject(NotificationsService);

This dead injection should be removed.


2. Type cast hides a real gap (auth.config.ts, lines ~254 / ~264)

Both buildAzureAdSettings and buildMockSettings spread baseSettings() and then cast with as UserManagerSettings:

function buildAzureAdSettings(config: EnvironmentConfig): UserManagerSettings {
  return {
    ...baseSettings(),
    authority: `...`,
    client_id: config.clientId,
    scope: `...`,
  } as UserManagerSettings;   // <-- suppresses TS
}

baseSettings() returns Partial<UserManagerSettings>, so the spread still produces a partial type and the cast hides any required-field violations. Consider removing Partial<> from baseSettings() and narrowing its return type to only the fields it provides, or using satisfies to keep the type check without widening:

function baseSettings() {
  return {
    redirect_uri: window.location.origin,
    ...
  } satisfies Partial<UserManagerSettings>;
}

Then the concrete builders can drop the cast because TypeScript can verify all required fields are present.


3. Unhandled getUser() rejection in runForegroundRefresh() (auth.service.ts)

this.userManager.getUser().then((user) => {
  this.user.set(user);
  ...
});

localStorage can throw under locked-down iOS privacy modes (as the inspectOidcStorage comment already notes). A .catch() is missing here. The loadInitialUser() equivalent uses a try/catch, so this is inconsistent. Recommend converting to an RxJS pipeline for consistency:

from(this.userManager.getUser())
  .pipe(catchError(() => of(null)), takeUntilDestroyed(this.destroyRef))
  .subscribe((user) => { ... });

4. init() is now async but callers are not in the diff

init() changed from void to Promise<void>. The diff doesn't show the call site (presumably an APP_INITIALIZER). If the caller doesn't await this promise, the cold-start sequence (redirect callback processing, initial user load) may not complete before the app renders. Please confirm the APP_INITIALIZER is awaiting the returned promise.


5. Minor: isApiRequest regex matches mid-path /api segments (token.interceptor.ts)

const isApiRequest = (url: string): boolean => /\/api(\/|$)/.test(url);

This would match a URL like /external-api/resource. For a relative-path Angular app this is very unlikely to matter in practice, but url.startsWith('/api') is clearer and more precise.


No test coverage for the migration path

The auth library replacement is a significant behavior change. The existing E2E tests presumably cover the login/logout happy path, but it would be worth confirming they exercise:

  • Successful login → redirect callback processing
  • Token refresh on 401 (the authRetryInterceptor path)
  • Foreground refresh on visibilitychange

If Playwright tests already cover these, this is fine as-is.


Summary

The migration is well thought-out and the core decisions are sound. The main things to address before merging:

  1. Remove the dead notifications injection.
  2. Fix the unhandled promise in runForegroundRefresh().
  3. Verify the APP_INITIALIZER awaits the now-async init().
  4. Optionally remove the as UserManagerSettings casts in favor of proper typing.

Per the Angular style guide (signals/resources, not rxjs where possible),
make AuthService signal-first and Observable-free:
- state stays in signals (user, isAuthenticated, userData)
- ensureAuthenticated() and refresh() return Promises (CanActivateFn accepts
  a Promise<boolean>); single-flight refresh is a tracked Promise instead of
  a shareReplay Observable
- proactive foreground refresh uses addEventListener + a timestamp throttle
  (cleaned up via DestroyRef.onDestroy) instead of merge/fromEvent/throttleTime

resource() isn't a fit here: refresh is event-driven and imperative (the guard
branches into a redirect side-effect), so signals + async/await is the
idiomatic non-rxjs model. authRetryInterceptor (an HttpInterceptorFn, which
must return an Observable) now wraps the refresh Promise with from().

https://claude.ai/code/session_01FncAPUn5nj7aeECLoa1bnD
@claude

claude Bot commented Jun 4, 2026

Copy link
Copy Markdown

Code Review

Overview

This PR migrates the Angular frontend authentication from angular-auth-oidc-client (v21) to the framework-agnostic oidc-client-ts (v3.5). The migration eliminates RxJS from the auth layer, switching to signal-based state and async/await throughout. Key changes: custom UserManager injection token, a new tokenInterceptor replacing the library's built-in one, and a proper flushFaro() mechanism for log delivery before redirects.


What Works Well

  • Signal-first, RxJS-free AuthService — aligns perfectly with project conventions. Replacing Observable/forkJoin/shareReplay with signal + Promise makes the control flow significantly easier to follow.
  • Single-flight refresh() — the inFlightRefresh promise-coalescing correctly prevents concurrent signinSilent calls, which was the main failure mode in the old library.
  • installForegroundRefresh() cleanup — proper destroyRef.onDestroy() teardown instead of relying on takeUntilDestroyed.
  • flushFaro() with bounded timeout — the 1500 ms deadline prevents a slow/offline backend from blocking navigation indefinitely.
  • inspectOidcStorage() simplification — removing the heuristic blob detection in favour of the well-known oidc.user: key prefix is cleaner and less fragile.
  • UserProfileService cleanup — dropping the { userData: {...} } wrapper extraction is a real simplification that the signal shape change enables.

Issues

isApiRequest is duplicated

The same regex /\/api(\/|$)/ appears in both token.interceptor.ts:9 and auth-retry.interceptor.ts:5. Extract it to a shared api-request.ts utility or keep it in one interceptor and import from there.

Multi-line docblocks exceed project conventions

auth.config.ts and auth.service.ts contain 8–13 line JSDoc blocks. AGENTS.md says "one short line max" for comments. The explanations in those blocks are genuinely useful, but most of the content belongs in commit messages / PR descriptions. Consider trimming to 1–2 lines per block.

ensureAuthenticated() reads storage after the signal was already updated

After awaiting an in-flight refresh (await this.inFlightRefresh), the guard calls this.userManager.getUser() and sets this.user again (auth.service.ts:452–453). But runRefresh() already called this.user.set(user) when the refresh settled. The extra storage round-trip is harmless but redundant — reading this.user() directly would be cheaper and consistent:

if (this.inFlightRefresh) {
  await this.inFlightRefresh.catch(() => null);
}
// Use the signal, not a second storage read
const user = this.user();

trackTransportSends() monkey-patches a private Faro API (faro.ts:129)

Wrapping transport.send works today but will silently break if Faro renames or re-shapes the method. The comment acknowledges there's no public flush API. This is acceptable as a workaround, but worth a // TODO: remove when Faro exposes a public flush so it gets revisited on the next Faro upgrade.

loadInitialUser() strips auth params even on signinRedirectCallback failure

auth.service.ts:654–656: history.replaceState(…) runs unconditionally in the returnedFromAuthority branch, including in the catch block. If the callback fails and the user reloads, ?code= is gone so ensureAuthenticated will trigger a fresh login — that's probably fine, but it also means the error is silently swallowed with no way to retry. Logging the failure case before clearing the URL would help debugging.


Minor

  • const handler = (): void => void this.runForegroundRefresh() — the outer : void annotation is redundant when void operator is already there. Either () => void this.runForegroundRefresh() or async () => { await this.runForegroundRefresh(); } reads more naturally.
  • package.json pins oidc-client-ts: ^3.1.0 but the lock file resolved 3.5.0. This is fine, just ensure 3.13.5 changelog was reviewed (especially around signinSilent behaviour changes).

Test Coverage

No tests are added for the rewritten AuthService. Given the project's E2E-first stance (Playwright), integration coverage likely catches most auth flows. Still, the refresh() single-flight logic and ensureAuthenticated() fallback paths are non-trivial enough to warrant a few unit tests if the test setup allows injecting a mock UserManager.


Security

  • Tokens remain in localStorage — same posture as the old library (DefaultLocalStorageService). XSS risk is unchanged.
  • PKCE is default-on in oidc-client-ts for response_type: 'code' — no explicit config needed, which is correct.
  • The ignoreNonceAfterRefresh workaround from the old config is no longer needed — oidc-client-ts does not revalidate the nonce on token refresh, so the iPhone PWA resume issue should be resolved by the library's own design rather than a workaround flag.

Overall this is a clean, well-motivated migration. The main ask is extracting the duplicated isApiRequest and trimming the docblocks to match project conventions.

@mucsi96 mucsi96 merged commit edb2023 into main Jun 4, 2026
5 checks passed
@mucsi96 mucsi96 deleted the claude/wonderful-davinci-dRQVY branch June 4, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants