Repository navigation
fix: do not forward redirects to a completed session on iOS - #1152
Conversation
🦋 Changeset detectedLatest commit: aa18f25 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
@federicobartoli is attempting to deploy a commit to the Nearform Team on Vercel. A member of the Team first needs to authorize it. |
|
|
||
| // If the callback already ran, the session returned above has completed: do not keep it, | ||
| // otherwise a later redirect would be forwarded to a finished session. | ||
| if (flowCompleted) { |
There was a problem hiding this comment.
The new __block BOOL flowCompleted flag is set in three callbacks (lines 405, 442, 568) and read once on this line 478 (and again at 581 in endSessionWithConfiguration:). The invariant "callback fired synchronously → flag is YES → drop the session" lives in the heads of maintainers, not in the code. AppAuth 2.0.0's OIDAuthorizationService.presentAuthorizationRequest: factory returns the session unconditionally even after didFinishWithResponse: fired synchronously, so a future present-callback author who forgets to set the flag silently re-introduces the exact crash this PR closes — no compile error, no test failure.
Why it needs to change
- Six present-call sites today (skipCodeExchange × externalUserAgent × iOS-13 availability, plus endSession). An eighth within a year is plausible (new provider, new browser, an
OIDAuthStateoverload the author hasn't read). Each new callback widens the open trap. - The post-present check on this very line is the only safeguard against the race; today it depends on every present-callback author remembering a magic three-word declaration that lives 100 lines earlier in the same method.
Code suggestion
Wrap the flag-set inside a tiny adapter the present callbacks always go through, so flowCompleted is unreachable directly:
__block BOOL didCallbackFireBeforePresentReturned = NO;
typeof(self) __weak weakSelf = self;
OIDAuthorizationCallback wrappedCallback = ^(OIDAuthorizationResponse *_Nullable response, NSError *_Nullable error) {
didCallbackFireBeforePresentReturned = YES;
typeof(self) strongSelf = weakSelf;
strongSelf->_currentSession = nil;
[UIApplication.sharedApplication endBackgroundTask:rnAppAuthTaskId];
rnAppAuthTaskId = UIBackgroundTaskInvalid;
if (response) {
resolve([self formatAuthorizationResponse:response withCodeVerifier:codeVerifier]);
} else {
[self rejectPromise:reject defaultCode:@"authentication_failed" error:error];
}
};
_currentSession = [OIDAuthorizationService presentAuthorizationRequest:request
externalUserAgent:externalUserAgent
callback:wrappedCallback];
if (didCallbackFireBeforePresentReturned) {
_currentSession = nil;
}Or, more aggressively: factor the six present calls plus their six callbacks into a single -presentAndMarkFired:externalUserAgent:callback: helper, and a sibling for end-session. The flag then exists in exactly one location per flow type.
Why this differs
- The crash this PR closes becomes structurally hard to regress instead of structurally easy: a new present-callback author can't avoid the flag because the wrappedCallback already sets it.
- All six present sites lose the duplicated
flowCompleted = YES;line plus the inline__blockdeclaration (18 lines removed, ~24 added across both methods). The maintainability win is bigger than the diff cost. - If a native test harness is ever wired up, the wrapper makes a one-shot unit test of "present fails synchronously →
_currentSessionis nil afterwards" trivially possible; today the same test would need to hook into six separate callback bodies.
There was a problem hiding this comment.
Thanks for the quick review, and for the approval!
Good point about the invariant. Today the three callbacks set the flag themselves (the six authorize call sites share two callback blocks, plus one for logout), so a new callback could indeed skip it. I'll go with your second suggestion and route every flow through a single helper that wraps the callback and decides whether to keep the session, so the bookkeeping lives in one place. I'll re-run the verification steps on the simulator and push an update.
Francesco-Voto
left a comment
There was a problem hiding this comment.
Good work!
Few comments from me as well:
The logic is correct. Clearing the session after the present call returns, and forwarding at most one redirect, closes the crash in #1151.
There are 7 present sites (6 in authorize, 1 in endSession) but only 3 callbacks.
The post-call check sits once per method, after all the branches. A new branch in authorize reuses an existing callback and is already covered.
The risk is a new flow copying the pattern without the check. A one-line comment at each check would be enough here. A helper that owns the flag would enforce it, but it can be a follow-up.
the callbacks do strongSelf->_currentSession = nil without a nil check, which would crash if self was deallocated. A if (!strongSelf) return; guard could go in a follow-up
|
Thanks both! @Francesco-Voto agreed on all points. I pushed a one-line comment at the @ilteoood I said I'd move this into a single helper here. Following Francesco's suggestion I'll keep this PR minimal instead and send the helper as a follow-up, together with the nil guard on |
|
LGTM, that's why I've already approved it |
|
Thanks @federicobartoli I've published this as 8.5.1 as I get ready to publish 9.0.0 later |
Fixes #1151
Description
On iOS,
RNAppAuthcould keep an AppAuth session that had already completed and forward a later redirect URL to it. With AppAuth < 2.1.0 that raises anNSExceptionand terminates the app. Details and reproduction are in #1151.Two changes in
ios/RNAppAuth.m, no API change:_currentSession, and then the assignment stored the finished session again. A__blockflag set by the callbacks is now checked after the call, in bothauthorizeandlogout.resumeExternalUserAgentFlowWithURL:now clears_currentSessiononce the session has accepted a URL. Before, the reference was kept until the code exchange finished, so a second matching URL arriving during the token request reached a finished session. The code exchange does not use the session: the token request only captures the authorization response and the callback.stateand PKCE validation are untouched; they stay inside AppAuth. A URL that arrives when no flow is pending returnsNOand falls through toRCTLinkingManager, as it already does when no login was ever started.Alternative I considered: requiring
AppAuth >= 2.1.0in the podspec would also remove the crash, but it changes other behaviour (2.1.0 removed the external-browser fallback, openid/AppAuth-iOS#954) and the module would still hold a finished session.Possibly related: #1080 (same exception and stack). I have not reproduced that trigger, so I am not marking it as fixed.
Steps to verify
iOS simulator with
examples/demo(AppAuth 2.0.0 from the demoPodfile.lock, Xcode 26.3, iPhone 17 Pro simulator on iOS 26.1).The crash is gone
iosCustomBrowser: 'chrome'inApp.tsx,googlechromesinLSApplicationQueriesSchemes).Unable to open Safari.xcrun simctl openurl booted "io.identityserver.demo:/oauthredirect?code=x&state=y".Login still works
iosCustomBrowser: 'safari': login in Safari, redirect back into the app, tokens are shown. This is the path that goes throughresumeExternalUserAgentFlowWithURL:.ASWebAuthenticationSession, noiosCustomBrowser): login completes, tokens are shown.Automated checks
jest(87 tests) andeslint .pass locally inpackages/react-native-app-auth. Neither covers the Objective-C file.RNAppAuth.mhas no native test harness (the existingnative-testsscript only coversRNAppAuthPresentation.m). Happy to add one if you can point me to the approach you prefer.Not tested
logout: the demo app has no logout button. The change there is the same pattern as inauthorize.