Skip to content

fix(ios): store the OAuth redirect exactly as Entra returns it - #43

Closed
blclo wants to merge 1 commit into
wildlife-reidfrom
fix/ios-oauth-redirect-trailing-slash
Closed

blclo wants to merge 1 commit into
wildlife-reidfrom
fix/ios-oauth-redirect-trailing-slash

Conversation

@blclo

@blclo blclo commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

iOS sign-in has never worked, and one character is why.

Entra normalises a custom-scheme redirect that carries no path. A request sent as org.ganesha.elebook://oauthredirect comes back as org.ganesha.elebook://oauthredirect/. AppAuth-iOS compares the callback against the configured redirect component by component — scheme, user, password, host, port, path — so '' != '/', shouldHandleURL: rejects it, and OIDExternalUserAgentIOS then discards the BOOL returned by resumeExternalUserAgentFlowWithURL:. The rejection is never reported. The authorization session waits forever, and the person watches a spinner that never stops.

Android never noticed. Its intent filter matches on the scheme alone (appAuthRedirectScheme in android/app/build.gradle), so the extra slash is irrelevant. Identical config, identical Entra registration, opposite outcomes — which is exactly why this survived: every Android tester succeeded while every iOS tester failed, and the failure produced no error to investigate.

Captured on an iPhone 15 Pro by routing the flow through the external browser so the callback surfaced in application(_:open:):

scheme:  org.ganesha.elebook
host:    oauthredirect
path:    '/'
resumed: REJECTED (url mismatch)
full:    org.ganesha.elebook://oauthredirect/?code=1.AQkA3Rhcn71...

Storing the redirect as Entra returns it makes the comparison succeed on iOS and changes nothing on Android, which never inspected the path. The pre-fix spelling is accepted and normalised, so existing configs self-heal instead of failing validation.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Screenshots / Screen Recordings

No UI change.

Checklist

General

  • My code follows the project's coding style and conventions
  • I have performed a self-review of my code
  • I have added/updated comments where the logic isn't self-evident
  • My changes generate no new warnings or errors

Testing

  • Existing tests pass locally (npm test)
  • I have added tests that prove my fix is effective or my feature works
  • I have tested on Android (physical device or emulator)
  • I have tested on iOS (physical device or simulator)
  • I have tested in light mode and dark mode

1034 tests pass plus tsc --noEmit. The device boxes stay unchecked until the end-to-end sign-in is confirmed on the iPhone that produced the capture above; the root cause is proven from the callback URL and from AppAuth's comparison replicated natively against these exact strings, which is not the same as a green run.

Android needs a regression check before merge. The reasoning that it is unaffected is sound — scheme-only intent filter, no path comparison — but it is reasoning, not a test, and this changes a value Android also sends.

React Native Specific

  • No new native module without corresponding platform implementation (Android + iOS)

Remainder N/A — configuration and docs only, no components or native modules touched.

Security

  • No secrets, API keys, or credentials are included in the code
  • User input is validated/sanitized where applicable

Additional Notes

Everyone signed in gets signed out once. getTokenStorageService digests all six config fields including redirectUrl, so the Keychain/Keystore service name moves and tokens stored under the old name are orphaned. Observations, packs and the local database are untouched — this costs one sign-in, and only on Android, since iOS has never had a session to lose. Worth a line in release notes.

The field contributes no isolation in any case: it is validated to a single constant, so every deployment already shares it. Excluding it from the digest would be defensible cleanup, but that moves the hash too, so it would buy a second forced sign-out for no benefit.

Add org.ganesha.elebook://oauthredirect/ to the Entra app registration before rolling this out. Entra matched the slash-less form on the way out and returned the slash-bearing one, so it normalises both, but registering the exact string the app now sends removes the assumption. Keep the existing entry so in-flight builds keep working.

Worth fixing upstream

OIDExternalUserAgentIOS calls resumeExternalUserAgentFlowWithURL:error: with error:nil and ignores the return value, so a URLMismatch becomes an infinite hang rather than an error. Every failure in that session also surfaces as OIDErrorCodeUserCanceledAuthorizationFlow, which SignInScreen suppresses without an alert. Three layers of silence over one mismatched character — the reason this took a device capture rather than a log to find.

iOS sign-in has never worked. It fails silently, and the trailing slash in
the redirect URI is why.

Entra normalises a custom-scheme redirect that carries no path, so a request
sent as `org.ganesha.elebook://oauthredirect` comes back as
`org.ganesha.elebook://oauthredirect/`. AppAuth-iOS compares the callback
against the configured redirect component by component, path included, so
'' != '/' and shouldHandleURL: rejects it. OIDExternalUserAgentIOS then
discards the BOOL from resumeExternalUserAgentFlowWithURL:, so the rejection
is never reported and the authorization session waits forever. The person
sees a spinner that never stops, with no error and nothing to retry.

Android never noticed. Its intent filter matches on the scheme alone
(appAuthRedirectScheme in android/app/build.gradle), so the extra slash is
irrelevant there. Same config, same Entra registration, opposite outcomes.

Captured on an iPhone 15 Pro by routing the flow through the external
browser so the callback surfaced in application(_:open:):

  scheme: org.ganesha.elebook
  host:   oauthredirect
  path:   '/'
  resumed: REJECTED (url mismatch)
  full:   org.ganesha.elebook://oauthredirect/?code=1.AQkA3Rhcn71...

Storing the redirect the way Entra returns it makes the comparison succeed
on iOS and changes nothing on Android, which never inspected the path. The
pre-fix spelling is accepted and normalised so existing configs self-heal
rather than failing validation.

One migration consequence: getTokenStorageService digests redirectUrl, so
the Keychain/Keystore service name moves and anyone already signed in is
signed out once and has to sign in again. Observations, packs and the local
database are untouched. iOS loses nothing, never having been able to sign in.
The field contributes no isolation in any case -- it is validated to a single
constant, so every deployment shares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@blclo

blclo commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into #42, which now carries this commit unchanged (ab0dd84) along with the rest of the iOS sign-in work.

Combining them on request: #42 was already the "stop failing silently" PR, and a redirect that makes iOS reject its own OAuth callback without reporting anything is the most extreme case of exactly that. Reviewing the mechanism alongside the timeouts and the background-download fix gives the full picture rather than three partial ones.

Nothing is lost — the commit, its tests and the rationale are intact in #42. Closing this in favour of that one.

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.

1 participant