Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion __tests__/unit/configureApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,27 @@ describe('deployment configuration', () => {
configureApp(options);
expect(fs.statSync(generated).mtimeMs).toBe(0);
});
});
});

describe('configureApp redirect normalisation', () => {
// Entra returns a custom-scheme redirect with a trailing slash appended.
// AppAuth-iOS compares the callback path against the configured redirect, so
// storing it without the slash makes iOS reject its own callback and hang.
// Android matches on scheme alone and is unaffected either way.
it('stores the redirect exactly as Entra returns it, with the trailing slash', () => {
const config = validateConfig({ ...example, redirectUrl: 'org.ganesha.elebook://oauthredirect/' });

expect(config.redirectUrl).toBe('org.ganesha.elebook://oauthredirect/');
});

it('normalises the pre-fix spelling so existing configs self-heal', () => {
const config = validateConfig({ ...example, redirectUrl: 'org.ganesha.elebook://oauthredirect' });

expect(config.redirectUrl).toBe('org.ganesha.elebook://oauthredirect/');
});

it('still rejects a redirect belonging to a different app', () => {
expect(() => validateConfig({ ...example, redirectUrl: 'different.app://oauthredirect/' }))
.toThrow(/redirectUrl/);
});
});
10 changes: 8 additions & 2 deletions __tests__/unit/deploymentConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const LONG_DEPLOYMENT = {
tenantId: '77777777-7777-4777-8777-777777777777',
mobileClientId: '88888888-8888-4888-8888-888888888888',
apiClientId: '99999999-9999-4999-8999-999999999999',
redirectUrl: 'org.ganesha.elebook://oauthredirect',
redirectUrl: 'org.ganesha.elebook://oauthredirect/',
};

describe('deployment token storage', () => {
Expand All @@ -40,8 +40,14 @@ describe('deployment token storage', () => {

it('derives the documented service name for the example deployment', () => {
// Pinned on purpose: changing the derivation orphans tokens stored by earlier builds.
//
// This value moved once, when redirectUrl gained the trailing slash that
// iOS needs to accept its own OAuth callback. Anyone already signed in is
// signed out by that change and has to sign in again; observations, packs
// and the local database are untouched. iOS loses nothing, never having
// been able to sign in at all.
expect(getTokenStorageService(example)).toBe(
'org.ganesha.elebook.entra.92eda79296364b345b9cb882a37983eb15de4e4ac4d7c6aa511b6d2d5ed543ed',
'org.ganesha.elebook.entra.df2facb18aac60dd0a72437d48a060371a0843065ae7fa0383d7a47f1d1a355b',
);
});

Expand Down
4 changes: 2 additions & 2 deletions deployment.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
"tenantId": "11111111-1111-4111-8111-111111111111",
"mobileClientId": "22222222-2222-4222-8222-222222222222",
"apiClientId": "33333333-3333-4333-8333-333333333333",
"redirectUrl": "org.ganesha.elebook://oauthredirect"
}
"redirectUrl": "org.ganesha.elebook://oauthredirect/"
}
4 changes: 2 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The app needs exactly six public strings. Start with [deployment.example.json](.
"tenantId": "11111111-1111-4111-8111-111111111111",
"mobileClientId": "22222222-2222-4222-8222-222222222222",
"apiClientId": "33333333-3333-4333-8333-333333333333",
"redirectUrl": "org.ganesha.elebook://oauthredirect"
"redirectUrl": "org.ganesha.elebook://oauthredirect/"
}
```

Expand All @@ -24,7 +24,7 @@ The app needs exactly six public strings. Start with [deployment.example.json](.
| `tenantId` | Tenant UUID. |
| `mobileClientId` | Native public-client registration UUID. |
| `apiClientId` | API registration UUID. |
| `redirectUrl` | Exactly `org.ganesha.elebook://oauthredirect`, matching the native registrations. |
| `redirectUrl` | Exactly `org.ganesha.elebook://oauthredirect/`, matching the native registrations. The trailing slash is required: Entra appends it to a custom-scheme redirect that has no path, and AppAuth-iOS compares the callback path against this value, so omitting it makes iOS reject its own callback and hang. The pre-fix spelling without the slash is accepted and normalised. |

Pack acquisition has a narrower limit: project IDs and pack version strings must fit within 40 UTF-16 code units. For live setup, keep `projectId` within that limit even though configuration accepts up to 128 characters; see [pack candidate paths](../src/services/packDownloadService/candidate.ts). Identities are digested into short directory names, so their length does not affect on-device paths.

Expand Down
27 changes: 26 additions & 1 deletion scripts/configure-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,29 @@ const CONFIG_FIELDS = [
'redirectUrl',
];
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const NATIVE_REDIRECT = 'org.ganesha.elebook://oauthredirect';
/**
* The trailing slash is load-bearing on iOS, and is not cosmetic.
*
* Entra normalises a custom-scheme redirect that has 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, including path, so
* '' != '/' and it rejects the callback. It then discards the rejection --
* OIDExternalUserAgentIOS ignores the BOOL from
* resumeExternalUserAgentFlowWithURL: -- and the authorization session waits
* forever. Sign-in spins with no error and no way 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. Storing the redirect exactly as Entra returns it makes the
* comparison succeed on iOS and changes nothing on Android.
*
* Confirmed on an iPhone 15 Pro: the callback arrives as
* `org.ganesha.elebook://oauthredirect/?code=...` with path '/'.
*/
const NATIVE_REDIRECT = 'org.ganesha.elebook://oauthredirect/';
/** Pre-fix spelling. Accepted and normalised so existing configs self-heal. */
const NATIVE_REDIRECT_LEGACY = 'org.ganesha.elebook://oauthredirect';

function validateApiUrl(value) {
let url;
Expand Down Expand Up @@ -49,6 +71,9 @@ function validateConfig(value) {
if (!/^[a-z0-9][a-z0-9_-]{0,127}$/i.test(config.projectId)) {
throw new Error('projectId must contain only letters, digits, underscores, and hyphens.');
}
if (config.redirectUrl === NATIVE_REDIRECT_LEGACY) {
config.redirectUrl = NATIVE_REDIRECT;
}
if (config.redirectUrl !== NATIVE_REDIRECT) {
throw new Error('redirectUrl must match the native redirect registered by this app.');
}
Expand Down
Loading