mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-05 16:08:17 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b94a294273 | ||
|
|
d345d15b0a | ||
|
|
4a4ed501d7 | ||
|
|
af580e9f17 | ||
|
|
91046725cc | ||
|
|
0376512dd5 |
@@ -81,24 +81,23 @@ tags:
|
|||||||
commands:
|
commands:
|
||||||
- tapOn: "More actions.*"
|
- tapOn: "More actions.*"
|
||||||
- tapOn: "Star|Unstar"
|
- tapOn: "Star|Unstar"
|
||||||
# A bulk action ends selection mode by itself once the server has the
|
# A bulk action ends selection mode by itself.
|
||||||
# change, so the bar stays up for a moment: wait for it to go rather than
|
- runFlow:
|
||||||
# tap Clear selection (the tap can land after the bar is gone).
|
when:
|
||||||
- extendedWaitUntil:
|
visible: "Clear selection"
|
||||||
notVisible: "Clear selection"
|
commands:
|
||||||
timeout: 15000
|
- tapOn: "Clear selection"
|
||||||
- longPressOn:
|
- longPressOn:
|
||||||
id: email-row
|
id: email-row
|
||||||
text: ".*Grüße aus München.*"
|
text: ".*Grüße aus München.*"
|
||||||
- extendedWaitUntil:
|
|
||||||
visible: "Clear selection"
|
|
||||||
timeout: 10000
|
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
notVisible: "Unstar|Star"
|
notVisible: "Unstar|Star"
|
||||||
commands:
|
commands:
|
||||||
- tapOn: "More actions.*"
|
- tapOn: "More actions.*"
|
||||||
- tapOn: "Unstar|Star"
|
- tapOn: "Unstar|Star"
|
||||||
- extendedWaitUntil:
|
- runFlow:
|
||||||
notVisible: "Clear selection"
|
when:
|
||||||
timeout: 15000
|
visible: "Clear selection"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Clear selection"
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ tags:
|
|||||||
# Each Q matches its page near the top. A PROOF the search lists under the
|
# Each Q matches its page near the top. A PROOF the search lists under the
|
||||||
# row would pass on the iPad without the page, so those differ. The Themes
|
# row would pass on the iPad without the page, so those differ. The Themes
|
||||||
# cards are one element each, named by the theme: check the page's text.
|
# cards are one element each, named by the theme: check the page's text.
|
||||||
# "Storage" also finds Account and Files, which puts Storage & backup third,
|
|
||||||
# under the keyboard in landscape (a tap there types on the keys): search
|
|
||||||
# "Offline", which only that page has.
|
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: folders, ROW: "Folders, .*", Q: "New folder", PROOF: ".*Inbox.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: tags, ROW: "Tags, .*", Q: "Tags", PROOF: "Email Tags"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: downloads, ROW: "Downloads, .*", Q: "Downloads", PROOF: "Email .*eml.* filename"}}
|
||||||
@@ -29,7 +26,7 @@ tags:
|
|||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: files, ROW: "Files, .*", Q: "Files", PROOF: "Default View"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: sidebar-apps, ROW: "Sidebar Apps, .*", Q: "Sidebar", PROOF: "Keep Apps Loaded"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: themes, ROW: "Themes, .*", Q: "Themes", PROOF: "Pick a colou?r theme.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Offline", PROOF: "Offline mail.*"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: storage, ROW: "Storage & backup, .*", Q: "Storage", PROOF: "Offline mail.*"}}
|
||||||
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
- runFlow: {file: ../subflows/settings-page.yaml, env: {NAME: about, ROW: "About, .*", Q: "About", PROOF: "This device"}}
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: tab-mail
|
id: tab-mail
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ appId: org.bulwarkmail.mobile
|
|||||||
- inputText: ${Q}
|
- inputText: ${Q}
|
||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 3000
|
timeout: 3000
|
||||||
# Now and then the simulator drops or garbles a typed character:
|
# Now and then the simulator drops or garbles a typed character
|
||||||
# type it again, a few times if need be.
|
# (seen: "Storagew,"): type it again, a few times if need be.
|
||||||
- repeat:
|
- repeat:
|
||||||
while:
|
while:
|
||||||
notVisible: ${ROW}
|
notVisible: ${ROW}
|
||||||
|
|||||||
+7
-6
@@ -57,12 +57,13 @@ module.exports = {
|
|||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: 'org.bulwarkmail.mobile',
|
bundleIdentifier: 'org.bulwarkmail.mobile',
|
||||||
buildNumber: IOS_BUILD_NUMBER,
|
buildNumber: IOS_BUILD_NUMBER,
|
||||||
config: {
|
// No usesNonExemptEncryption here. PGP and S/MIME (#58) are non-exempt
|
||||||
// PGP and S/MIME (#58) add end-to-end encryption beyond HTTPS/TLS,
|
// encryption, but only standard algorithms from open-source libraries
|
||||||
// so the app declares non-exempt encryption; App Store Connect asks
|
// and the app is not on the French store, so App Store Connect keeps no
|
||||||
// for the export compliance documentation on upload.
|
// declaration and has no ITSEncryptionExportComplianceCode to give. An
|
||||||
usesNonExemptEncryption: true,
|
// Info.plist saying YES without that code fails upload validation
|
||||||
},
|
// (90592), so the key stays out and scripts/testflight-public.mjs
|
||||||
|
// answers the question for each build through the API.
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.87",
|
"version": "0.1.90",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.87",
|
"version": "0.1.90",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-mobile",
|
"name": "bulwark-mobile",
|
||||||
"version": "0.1.87",
|
"version": "0.1.90",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -43,16 +43,14 @@ function findHierarchy(obj, seen = new Set()) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maestro 1 writes commands-<flow>.json, Maestro 2 <flow>/commands.json.
|
for (const file of walkFiles(root).filter((f) => /commands-.*\.json$/.test(f))) {
|
||||||
for (const file of walkFiles(root).filter((f) => /(^|[\\/])commands(-[^\\/]*)?\.json$/.test(f))) {
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const base = path.basename(file);
|
out.push(`== ${path.basename(file)}`);
|
||||||
out.push(`== ${base === 'commands.json' ? `${path.basename(path.dirname(file))} / ${base}` : base}`);
|
|
||||||
for (const entry of Array.isArray(data) ? data : []) {
|
for (const entry of Array.isArray(data) ? data : []) {
|
||||||
const command = entry.command || {};
|
const command = entry.command || {};
|
||||||
const type = Object.keys(command).find((k) => command[k]) || '?';
|
const type = Object.keys(command).find((k) => command[k]) || '?';
|
||||||
|
|||||||
+11
-31
@@ -8,8 +8,8 @@
|
|||||||
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
|
# $OUT/screenshots, $OUT/run.mp4, $OUT/app.log, $OUT/steps.txt.
|
||||||
#
|
#
|
||||||
# Each flow runs in its own Maestro process with a time cap: when the XCTest
|
# Each flow runs in its own Maestro process with a time cap: when the XCTest
|
||||||
# driver dies (it does, now and then), only that flow is affected: it runs
|
# driver dies (it does, now and then), only that flow fails and the next one
|
||||||
# once more with a fresh driver, and the next flow starts a fresh one too.
|
# starts a fresh driver.
|
||||||
#
|
#
|
||||||
# Secrets: Maestro's debug output holds every typed text, passwords included,
|
# Secrets: Maestro's debug output holds every typed text, passwords included,
|
||||||
# and artifacts of this public repo are public. Only the warm-up (which types
|
# and artifacts of this public repo are public. Only the warm-up (which types
|
||||||
@@ -75,43 +75,23 @@ if [ $STATUS -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
FAILED=()
|
FAILED=()
|
||||||
RETRIED=()
|
|
||||||
for f in "${FLOWS[@]}"; do
|
for f in "${FLOWS[@]}"; do
|
||||||
name=$(basename "$f" .yaml)
|
name=$(basename "$f" .yaml)
|
||||||
# When the XCTest driver dies during a flow ("Device became unreachable"),
|
started=$(date +%s)
|
||||||
# the flow gets one more try in a fresh Maestro process, which starts a
|
echo "::group::$name"
|
||||||
# new driver. Any other failure (an assertion, a missing element, the
|
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
||||||
# time cap) stays a failure. The driver's death shows in Maestro's log in
|
--format junit --output "$OUT/junit/$name.xml" \
|
||||||
# the flow's debug output (never uploaded; grep -q prints nothing). Piping
|
--debug-output "$DEBUG_DIR/$name" \
|
||||||
# Maestro's console output instead loses the failure reasons it prints.
|
"${ARGS[@]}" "$f")
|
||||||
for attempt in 1 2; do
|
rc=$?
|
||||||
suffix=""
|
echo "::endgroup::"
|
||||||
[ $attempt -gt 1 ] && suffix="-retry"
|
echo "$name: exit $rc after $(( $(date +%s) - started )) s"
|
||||||
started=$(date +%s)
|
|
||||||
echo "::group::$name$suffix"
|
|
||||||
(cd "$OUT/screenshots" && capped maestro --device "$UDID" test \
|
|
||||||
--format junit --output "$OUT/junit/$name.xml" \
|
|
||||||
--debug-output "$DEBUG_DIR/$name$suffix" \
|
|
||||||
"${ARGS[@]}" "$f")
|
|
||||||
rc=$?
|
|
||||||
echo "::endgroup::"
|
|
||||||
echo "$name$suffix: exit $rc after $(( $(date +%s) - started )) s"
|
|
||||||
[ $rc -eq 0 ] && break
|
|
||||||
[ $attempt -eq 2 ] && break
|
|
||||||
grep -rqE 'DeviceUnreachableException|Device became unreachable|Transport unreachable' \
|
|
||||||
"$DEBUG_DIR/$name$suffix" 2>/dev/null || break
|
|
||||||
echo "::warning::$name: the Maestro driver became unreachable, running the flow once more"
|
|
||||||
RETRIED+=("$name")
|
|
||||||
done
|
|
||||||
if [ $rc -ne 0 ]; then
|
if [ $rc -ne 0 ]; then
|
||||||
FAILED+=("$name")
|
FAILED+=("$name")
|
||||||
# What the screen showed when it failed.
|
# What the screen showed when it failed.
|
||||||
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
|
xcrun simctl io "$UDID" screenshot "$OUT/screenshots/FAILED-$name.png" >/dev/null 2>&1 || true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ ${#RETRIED[@]} -gt 0 ]; then
|
|
||||||
echo "::warning::Flows run again after the driver died: ${RETRIED[*]}"
|
|
||||||
fi
|
|
||||||
if [ ${#FAILED[@]} -gt 0 ]; then
|
if [ ${#FAILED[@]} -gt 0 ]; then
|
||||||
echo "::error::Failed flows: ${FAILED[*]}"
|
echo "::error::Failed flows: ${FAILED[*]}"
|
||||||
STATUS=1
|
STATUS=1
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ if (!group) {
|
|||||||
// ── Wait for the build ─────────────────────────────────────────
|
// ── Wait for the build ─────────────────────────────────────────
|
||||||
const query =
|
const query =
|
||||||
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
|
`/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` +
|
||||||
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`;
|
`&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState,usesNonExemptEncryption`;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
let build;
|
let build;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -121,6 +121,18 @@ for (;;) {
|
|||||||
}
|
}
|
||||||
console.log(`Build ${version} (${buildNumber}) is processed.`);
|
console.log(`Build ${version} (${buildNumber}) is processed.`);
|
||||||
|
|
||||||
|
// ── Export compliance ──────────────────────────────────────────
|
||||||
|
// The app uses non-exempt encryption (PGP and S/MIME, #58) with standard
|
||||||
|
// algorithms only and is not on the French store: App Store Connect needs no
|
||||||
|
// documentation, so Info.plist has no key and the answer is given here.
|
||||||
|
if (build.attributes.usesNonExemptEncryption == null) {
|
||||||
|
const answer = await api('PATCH', `/v1/builds/${build.id}`, {
|
||||||
|
data: { type: 'builds', id: build.id, attributes: { usesNonExemptEncryption: true } },
|
||||||
|
});
|
||||||
|
if (!answer.ok) fail('Answering export compliance', answer);
|
||||||
|
console.log('Export compliance answered: standard encryption, no documentation needed.');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Add it to the group ────────────────────────────────────────
|
// ── Add it to the group ────────────────────────────────────────
|
||||||
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
|
const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, {
|
||||||
data: [{ type: 'builds', id: build.id }],
|
data: [{ type: 'builds', id: build.id }],
|
||||||
@@ -150,7 +162,7 @@ const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`);
|
|||||||
if (!detail.ok) fail('Reading the build state', detail);
|
if (!detail.ok) fail('Reading the build state', detail);
|
||||||
const external = detail.json.data.attributes.externalBuildState;
|
const external = detail.json.data.attributes.externalBuildState;
|
||||||
if (external === 'MISSING_EXPORT_COMPLIANCE') {
|
if (external === 'MISSING_EXPORT_COMPLIANCE') {
|
||||||
console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.');
|
console.error('The build still has no export compliance answer; answer it in App Store Connect > TestFlight.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (external === 'READY_FOR_BETA_SUBMISSION') {
|
if (external === 'READY_FOR_BETA_SUBMISSION') {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ vi.mock('../booking-calendar-actions', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import type { Email } from '../../../../api/types';
|
import type { Email } from '../../../../api/types';
|
||||||
|
import { usePolicyStore } from '../../../../stores/policy-store';
|
||||||
import { useSettingsStore } from '../../../../stores/settings-store';
|
import { useSettingsStore } from '../../../../stores/settings-store';
|
||||||
import { StructuredMailBanner } from '../StructuredMailBanner';
|
import { StructuredMailBanner } from '../StructuredMailBanner';
|
||||||
import { byTestId, render, textOf } from './render-native';
|
import { byTestId, render, textOf } from './render-native';
|
||||||
@@ -37,7 +38,11 @@ function mail(ar: string | null, subject = 'Your order'): Email {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialSettings = useSettingsStore.getState();
|
const initialSettings = useSettingsStore.getState();
|
||||||
afterEach(() => useSettingsStore.setState(initialSettings, true));
|
const initialPolicy = usePolicyStore.getState();
|
||||||
|
afterEach(() => {
|
||||||
|
useSettingsStore.setState(initialSettings, true);
|
||||||
|
usePolicyStore.setState(initialPolicy, true);
|
||||||
|
});
|
||||||
|
|
||||||
describe('StructuredMailBanner', () => {
|
describe('StructuredMailBanner', () => {
|
||||||
it('shows the order card of a verified mail', () => {
|
it('shows the order card of a verified mail', () => {
|
||||||
@@ -67,6 +72,8 @@ describe('StructuredMailBanner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads product images only when remote content would load', () => {
|
it('loads product images only when remote content would load', () => {
|
||||||
|
// The webmail's config is known and allows third-party traffic.
|
||||||
|
usePolicyStore.setState({ configPending: false, links: {} });
|
||||||
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] });
|
useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: [] });
|
||||||
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
|
||||||
useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] });
|
useSettingsStore.setState({ trustedSenders: ['orders@shop.example'] });
|
||||||
@@ -74,4 +81,12 @@ describe('StructuredMailBanner', () => {
|
|||||||
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
|
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
|
||||||
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1);
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads no product images while the webmail config is unknown or forbids third-party traffic', () => {
|
||||||
|
useSettingsStore.setState({ externalContentPolicy: 'allow', trustedSenders: [] });
|
||||||
|
usePolicyStore.setState({ configPending: true, links: {} });
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
|
||||||
|
usePolicyStore.setState({ configPending: false, links: { noThirdPartyTraffic: true } });
|
||||||
|
expect(byTestId(render(createElement(StructuredMailBanner, { email: mail(PASS) })), 'structured-mail-item-image')).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
// With PGP and S/MIME in the app (#58) the encryption is no longer only
|
// PGP and S/MIME (#58) are non-exempt encryption with standard algorithms, and
|
||||||
// HTTPS/TLS, so the App Store export compliance answer must say so.
|
// the app is not on the French store, so App Store Connect has no declaration
|
||||||
|
// and no compliance code. Info.plist must not claim YES (upload validation
|
||||||
|
// rejects it without the code, 90592) nor NO (that would be untrue); the
|
||||||
|
// TestFlight script answers per build instead.
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '../../..');
|
||||||
|
|
||||||
describe('export compliance', () => {
|
describe('export compliance', () => {
|
||||||
it('declares non-exempt encryption for iOS', () => {
|
it('leaves the encryption key out of the iOS config', () => {
|
||||||
const src = fs.readFileSync(path.resolve(__dirname, '../../../app.config.js'), 'utf8');
|
const src = fs.readFileSync(path.join(root, 'app.config.js'), 'utf8');
|
||||||
expect(src).toMatch(/usesNonExemptEncryption: true/);
|
expect(src).not.toMatch(/usesNonExemptEncryption\s*:/);
|
||||||
expect(src).not.toMatch(/usesNonExemptEncryption: false/);
|
});
|
||||||
|
|
||||||
|
it('answers non-exempt encryption for each TestFlight build', () => {
|
||||||
|
const src = fs.readFileSync(path.join(root, 'scripts/testflight-public.mjs'), 'utf8');
|
||||||
|
expect(src).toMatch(/attributes: \{ usesNonExemptEncryption: true \}/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,52 @@ describe('loginWithPkce on Stalwart', () => {
|
|||||||
expect(scopes).not.toContain('profile');
|
expect(scopes).not.toContain('profile');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 1.0.3 dropped first-party clients: the bulwark-mobile client the webmail
|
||||||
|
// setup registered now allows only openid and offline_access, and refuses
|
||||||
|
// the whole request with invalid_scope.
|
||||||
|
const stalwart103: OAuthMetadata = {
|
||||||
|
...stalwart,
|
||||||
|
scopes_supported: [
|
||||||
|
...stalwart.scopes_supported!,
|
||||||
|
'urn:stalwart:oauth:scope:security',
|
||||||
|
'urn:stalwart:oauth:scope:settings',
|
||||||
|
'urn:stalwart:oauth:scope:full',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('registers its own client on 1.0.3 when bulwark-mobile does not allow the scopes it asks for', async () => {
|
||||||
|
const checked: URLSearchParams[] = [];
|
||||||
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/api/auth/client')) {
|
||||||
|
const q = new URL(url).searchParams;
|
||||||
|
checked.push(q);
|
||||||
|
if (q.get('client_id') !== 'bulwark-mobile') return answer({ error: 'invalid_client' });
|
||||||
|
const extra = (q.get('scope') ?? '').split(' ').filter((s) => s && s !== 'openid' && s !== 'offline_access');
|
||||||
|
return extra.length
|
||||||
|
? answer({ error: 'invalid_request', redirect: true, location: `${REGISTERED_REDIRECT_URI}?error=invalid_scope` })
|
||||||
|
: answer({ name: 'Bulwark Mobile (auto-configured)', scopes: ['Full access to your account'] });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/auth/register')) return answer({ client_id: 'swc1.own' }, 201);
|
||||||
|
return answer({}, 404);
|
||||||
|
});
|
||||||
|
const { params } = await openedSession(stalwart103);
|
||||||
|
|
||||||
|
expect(params.get('client_id')).toBe('swc1.own');
|
||||||
|
// The check asked with the scopes the browser then asks for.
|
||||||
|
expect(checked[0].get('scope')).toBe(params.get('scope'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks 1.0.3 for the account's settings, but not for full access", async () => {
|
||||||
|
fetchMock.mockImplementation(async () => answer({ status: 404 }, 404));
|
||||||
|
const { params } = await openedSession(stalwart103);
|
||||||
|
const scopes = params.get('scope')!.split(' ');
|
||||||
|
|
||||||
|
expect(scopes).toEqual(expect.arrayContaining(['urn:stalwart:oauth:scope:security', 'urn:stalwart:oauth:scope:settings']));
|
||||||
|
expect(scopes).not.toContain('urn:stalwart:oauth:scope:full');
|
||||||
|
// A server that doesn't list them isn't asked for them.
|
||||||
|
expect(pkceScopes(stalwart)).not.toContain('urn:stalwart:');
|
||||||
|
});
|
||||||
|
|
||||||
it('asks another identity provider for the usual OpenID scopes', () => {
|
it('asks another identity provider for the usual OpenID scopes', () => {
|
||||||
expect(pkceScopes({ ...metadata, scopes_supported: ['openid', 'email', 'profile', 'offline_access', 'groups'] }))
|
expect(pkceScopes({ ...metadata, scopes_supported: ['openid', 'email', 'profile', 'offline_access', 'groups'] }))
|
||||||
.toBe('openid email profile offline_access');
|
.toBe('openid email profile offline_access');
|
||||||
|
|||||||
@@ -34,16 +34,26 @@ interface Call { url: string; method: string; body?: string }
|
|||||||
// A fake Stalwart of either line. `v1` knows no client but the ones it
|
// A fake Stalwart of either line. `v1` knows no client but the ones it
|
||||||
// registered (ids `swc1.<n>`) and those in `admin` (registered by an admin
|
// registered (ids `swc1.<n>`) and those in `admin` (registered by an admin
|
||||||
// for the given redirect URI), takes `{query, consent}` and answers with a
|
// for the given redirect URI), takes `{query, consent}` and answers with a
|
||||||
// location; 0.16 has no client check and answers `client_code`.
|
// location; 0.16 has no client check and answers `client_code`. An admin
|
||||||
|
// client listed in `adminScopes` allows only those scopes besides openid and
|
||||||
|
// offline_access (1.0.3, which has no first-party clients), and a request
|
||||||
|
// naming another is refused with invalid_scope; any other client allows all.
|
||||||
function fakeServer(
|
function fakeServer(
|
||||||
version: '0.16' | '1.0',
|
version: '0.16' | '1.0',
|
||||||
answer?: (query: URLSearchParams) => unknown,
|
answer?: (query: URLSearchParams) => unknown,
|
||||||
admin: Record<string, string> = {},
|
admin: Record<string, string> = {},
|
||||||
|
adminScopes: Record<string, string[]> = {},
|
||||||
) {
|
) {
|
||||||
const calls: Call[] = [];
|
const calls: Call[] = [];
|
||||||
let registered = 0;
|
let registered = 0;
|
||||||
const knows = (clientId: string, redirectUri: string | null) =>
|
const knows = (clientId: string, redirectUri: string | null) =>
|
||||||
clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri);
|
clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri);
|
||||||
|
const allows = (clientId: string, scope: string | null) => {
|
||||||
|
const allowed = adminScopes[clientId];
|
||||||
|
if (!allowed) return true;
|
||||||
|
return (scope ?? '').split(' ').every((s) => !s || s === 'openid' || s === 'offline_access' || allowed.includes(s));
|
||||||
|
};
|
||||||
|
const scopeRefused = (redirectUri: string | null) => `${redirectUri}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed+for+this+client.`;
|
||||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
||||||
calls.push(call);
|
calls.push(call);
|
||||||
@@ -51,9 +61,13 @@ function fakeServer(
|
|||||||
if (path === '/api/auth/client') {
|
if (path === '/api/auth/client') {
|
||||||
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
||||||
const params = new URL(url).searchParams;
|
const params = new URL(url).searchParams;
|
||||||
return json(knows(params.get('client_id') ?? '', params.get('redirect_uri'))
|
const clientId = params.get('client_id') ?? '';
|
||||||
|
if (!knows(clientId, params.get('redirect_uri'))) {
|
||||||
|
return json({ error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
||||||
|
}
|
||||||
|
return json(allows(clientId, params.get('scope'))
|
||||||
? { name: 'Bulwark Mobile' }
|
? { name: 'Bulwark Mobile' }
|
||||||
: { error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
: { error: 'invalid_request', redirect: true, location: scopeRefused(params.get('redirect_uri')) });
|
||||||
}
|
}
|
||||||
if (path === '/auth/register') {
|
if (path === '/auth/register') {
|
||||||
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
|
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
|
||||||
@@ -67,6 +81,7 @@ function fakeServer(
|
|||||||
const query = new URLSearchParams(body.query);
|
const query = new URLSearchParams(body.query);
|
||||||
if (answer) return json(answer(query));
|
if (answer) return json(answer(query));
|
||||||
if (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
|
if (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
|
||||||
|
if (!allows(query.get('client_id') ?? '', query.get('scope'))) return json({ type: 'redirect', location: scopeRefused(query.get('redirect_uri')) });
|
||||||
return json({
|
return json({
|
||||||
type: 'authenticated',
|
type: 'authenticated',
|
||||||
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
|
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
|
||||||
@@ -153,6 +168,30 @@ describe('exchangePasswordForTokens', () => {
|
|||||||
expect(tokenCall(calls).has('client_secret')).toBe(false);
|
expect(tokenCall(calls).has('client_secret')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers its own client on Stalwart 1.0.3, where bulwark-mobile lost its first-party access', async () => {
|
||||||
|
// 1.0.3 has no first-party clients: bulwark-mobile now allows only openid
|
||||||
|
// and offline_access and refuses the app's request with invalid_scope.
|
||||||
|
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }, { 'bulwark-mobile': [] });
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe('swc1.1');
|
||||||
|
expect(authCalls(calls)).toHaveLength(1);
|
||||||
|
const register = JSON.parse(calls.find((c) => c.url.endsWith('/auth/register'))!.body!);
|
||||||
|
expect(register.scope).toContain('urn:stalwart:oauth:scope:security');
|
||||||
|
expect(register.scope).not.toContain('urn:stalwart:oauth:scope:full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks again when a remembered bulwark-mobile no longer allows the scopes, without a second password check failing', async () => {
|
||||||
|
fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
|
||||||
|
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
// The server is upgraded to 1.0.3 while the app keeps running.
|
||||||
|
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }, { 'bulwark-mobile': [] });
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '654321');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe('swc1.1');
|
||||||
|
expect(tokenCall(calls).get('client_id')).toBe('swc1.1');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the registered client per server for the next sign-in', async () => {
|
it('keeps the registered client per server for the next sign-in', async () => {
|
||||||
fakeServer('1.0');
|
fakeServer('1.0');
|
||||||
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|||||||
+15
-4
@@ -14,9 +14,11 @@ import * as WebBrowser from 'expo-web-browser';
|
|||||||
import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect, secureFetch } from './client-cert';
|
import { fetchWithoutForeignRedirect, followedOnlyAllowedRedirects, mayFollowRedirect, secureFetch } from './client-cert';
|
||||||
import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth';
|
import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth';
|
||||||
import {
|
import {
|
||||||
|
forgetLoginClient,
|
||||||
generateCodeChallenge,
|
generateCodeChallenge,
|
||||||
generateCodeVerifier,
|
generateCodeVerifier,
|
||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
|
STALWART_ACCOUNT_SCOPES,
|
||||||
DEFAULT_CLIENT_ID,
|
DEFAULT_CLIENT_ID,
|
||||||
MOBILE_CLIENT_ID,
|
MOBILE_CLIENT_ID,
|
||||||
REGISTERED_LOGOUT_REDIRECT_URI,
|
REGISTERED_LOGOUT_REDIRECT_URI,
|
||||||
@@ -142,7 +144,8 @@ const DATA_SCOPE_PREFIX = 'urn:ietf:params:oauth:scope:';
|
|||||||
* advertises OAuth data scopes is Stalwart: 1.0 gives a client it doesn't
|
* advertises OAuth data scopes is Stalwart: 1.0 gives a client it doesn't
|
||||||
* treat as first-party only what these name, and refuses a scope it doesn't
|
* treat as first-party only what these name, and refuses a scope it doesn't
|
||||||
* list (`email`, `profile`) with `invalid_scope`. For such a provider the
|
* list (`email`, `profile`) with `invalid_scope`. For such a provider the
|
||||||
* scopes it doesn't list are dropped and every data scope it lists is added;
|
* scopes it doesn't list are dropped and every data scope it lists is added,
|
||||||
|
* with the account-settings scopes 1.0.3 lists (STALWART_ACCOUNT_SCOPES);
|
||||||
* any other provider gets the usual OIDC set.
|
* any other provider gets the usual OIDC set.
|
||||||
*/
|
*/
|
||||||
export function pkceScopes(metadata: OAuthMetadata): string {
|
export function pkceScopes(metadata: OAuthMetadata): string {
|
||||||
@@ -151,7 +154,8 @@ export function pkceScopes(metadata: OAuthMetadata): string {
|
|||||||
if (!supported?.length) return wanted.join(' ');
|
if (!supported?.length) return wanted.join(' ');
|
||||||
const kept = wanted.filter((s) => supported.includes(s));
|
const kept = wanted.filter((s) => supported.includes(s));
|
||||||
const data = supported.filter((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
|
const data = supported.filter((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
|
||||||
if (data.length) return [...new Set([...kept, ...data])].join(' ');
|
const account = STALWART_ACCOUNT_SCOPES.filter((s) => supported.includes(s));
|
||||||
|
if (data.length) return [...new Set([...kept, ...data, ...account])].join(' ');
|
||||||
return kept.join(' ') || supported.join(' ');
|
return kept.join(' ') || supported.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +174,10 @@ async function pkceClient(serverUrl: string, metadata: OAuthMetadata): Promise<{
|
|||||||
const stalwart = !!metadata.scopes_supported?.some((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
|
const stalwart = !!metadata.scopes_supported?.some((s) => typeof s === 'string' && s.startsWith(DATA_SCOPE_PREFIX));
|
||||||
if (onServer || stalwart) {
|
if (onServer || stalwart) {
|
||||||
try {
|
try {
|
||||||
return await resolveLoginClient(serverUrl, { registrationEndpoint: registrationEndpoint(metadata) });
|
return await resolveLoginClient(serverUrl, {
|
||||||
|
registrationEndpoint: registrationEndpoint(metadata),
|
||||||
|
scope: pkceScopes(metadata),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Unreachable: say so, rather than let the browser show the server
|
// Unreachable: say so, rather than let the browser show the server
|
||||||
// refusing a client it doesn't know.
|
// refusing a client it doesn't know.
|
||||||
@@ -232,7 +239,11 @@ export async function loginWithPkce(
|
|||||||
|
|
||||||
const cb = parseCallbackParams(result.url);
|
const cb = parseCallbackParams(result.url);
|
||||||
const err = cb.get('error');
|
const err = cb.get('error');
|
||||||
if (err) throw new HandoffError(cb.get('error_description') || err);
|
if (err) {
|
||||||
|
// A remembered client the server no longer takes: the next try asks again.
|
||||||
|
if (!opts?.clientId && (err === 'invalid_client' || err === 'invalid_scope')) forgetLoginClient(serverUrl);
|
||||||
|
throw new HandoffError(cb.get('error_description') || err);
|
||||||
|
}
|
||||||
if (cb.get('state') !== state) throw new HandoffError('State mismatch');
|
if (cb.get('state') !== state) throw new HandoffError('State mismatch');
|
||||||
const code = cb.get('code');
|
const code = cb.get('code');
|
||||||
if (!code) throw new HandoffError('Sign-in response missing authorization code');
|
if (!code) throw new HandoffError('Sign-in response missing authorization code');
|
||||||
|
|||||||
+48
-16
@@ -76,7 +76,20 @@ export const STALWART_DATA_SCOPES = [
|
|||||||
'urn:ietf:params:oauth:scope:files',
|
'urn:ietf:params:oauth:scope:files',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES].join(' ');
|
/**
|
||||||
|
* Stalwart 1.0.3's scopes for the account's own settings (password, 2FA, app
|
||||||
|
* passwords, encryption keys), which until then only a first-party client
|
||||||
|
* reached. 1.0.3 dropped first-party clients, so `bulwark-mobile` too gets
|
||||||
|
* only what its scopes name. `urn:stalwart:oauth:scope:full` isn't asked for:
|
||||||
|
* only a client a superuser allowed it may, and a request naming a scope the
|
||||||
|
* client lacks is refused as a whole. Earlier servers skip these names.
|
||||||
|
*/
|
||||||
|
export const STALWART_ACCOUNT_SCOPES = [
|
||||||
|
'urn:stalwart:oauth:scope:security',
|
||||||
|
'urn:stalwart:oauth:scope:settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES, ...STALWART_ACCOUNT_SCOPES].join(' ');
|
||||||
|
|
||||||
export class LoginEndpointMissingError extends Error {
|
export class LoginEndpointMissingError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -137,6 +150,11 @@ export function resetLoginClients(): void {
|
|||||||
loginClients.clear();
|
loginClients.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop the client remembered for `serverUrl`, which the server just refused. */
|
||||||
|
export function forgetLoginClient(serverUrl: string): void {
|
||||||
|
loginClients.delete(trimBase(serverUrl));
|
||||||
|
}
|
||||||
|
|
||||||
function rememberClient(base: string, client: LoginClient): void {
|
function rememberClient(base: string, client: LoginClient): void {
|
||||||
if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) {
|
if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) {
|
||||||
const oldest = loginClients.keys().next().value;
|
const oldest = loginClients.keys().next().value;
|
||||||
@@ -209,14 +227,18 @@ async function registerPublicClient(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't
|
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't
|
||||||
* have): does the server take `clientId` on `redirectUri`? `busy` for a
|
* have): does the server take `clientId` on `redirectUri` asking for
|
||||||
* server having a bad moment (5xx, 429), `none` where there is no such
|
* `scope`? A client registered without the scopes the app asks for
|
||||||
* check; an unreachable server throws `NetworkError`.
|
* (`bulwark-mobile` on 1.0.3, which dropped first-party clients) counts as
|
||||||
|
* unknown, since the server refuses the whole request with `invalid_scope`.
|
||||||
|
* `busy` for a server having a bad moment (5xx, 429), `none` where there is
|
||||||
|
* no such check; an unreachable server throws `NetworkError`.
|
||||||
*/
|
*/
|
||||||
async function checkClient(
|
async function checkClient(
|
||||||
base: string,
|
base: string,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
|
scope: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
@@ -224,6 +246,7 @@ async function checkClient(
|
|||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
state: 'probe',
|
state: 'probe',
|
||||||
|
scope,
|
||||||
code_challenge: 'A'.repeat(43),
|
code_challenge: 'A'.repeat(43),
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
});
|
});
|
||||||
@@ -249,7 +272,8 @@ async function checkClient(
|
|||||||
if (!response.ok) return 'none';
|
if (!response.ok) return 'none';
|
||||||
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||||
if (!body || typeof body !== 'object') return 'none';
|
if (!body || typeof body !== 'object') return 'none';
|
||||||
// `invalid_client` (unknown id) or a redirect URI the client doesn't list.
|
// `invalid_client` (unknown id), a redirect URI the client doesn't list,
|
||||||
|
// or (as a `redirect` carrying `invalid_scope`) a scope it doesn't allow.
|
||||||
return typeof body.error === 'string' && body.error ? 'unknown' : 'known';
|
return typeof body.error === 'string' && body.error ? 'unknown' : 'known';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,22 +282,25 @@ async function checkClient(
|
|||||||
* `bulwark-mobile` where the admin's OAuth setup registered it, else the
|
* `bulwark-mobile` where the admin's OAuth setup registered it, else the
|
||||||
* webmail's client if it takes the app's redirect URI, else a public client
|
* webmail's client if it takes the app's redirect URI, else a public client
|
||||||
* the app registers itself, at `registrationEndpoint` when the OAuth
|
* the app registers itself, at `registrationEndpoint` when the OAuth
|
||||||
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as
|
* metadata names one. Either of the first two counts only if it allows
|
||||||
* before. A server having a bad moment (5xx, 429) gets the default without
|
* `scope`, the scopes the sign-in will ask for. Any other server, and 0.16,
|
||||||
* it being remembered; an unreachable one throws `NetworkError`, as the
|
* gets `bulwark-webmail` as before. A server having a bad moment (5xx, 429)
|
||||||
* default would only end in the server refusing a client it doesn't know.
|
* gets the default without it being remembered; an unreachable one throws
|
||||||
|
* `NetworkError`, as the default would only end in the server refusing a
|
||||||
|
* client it doesn't know.
|
||||||
*/
|
*/
|
||||||
export async function resolveLoginClient(
|
export async function resolveLoginClient(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
opts?: { registrationEndpoint?: string; signal?: AbortSignal },
|
opts?: { registrationEndpoint?: string; scope?: string; signal?: AbortSignal },
|
||||||
): Promise<LoginClient> {
|
): Promise<LoginClient> {
|
||||||
const base = trimBase(serverUrl);
|
const base = trimBase(serverUrl);
|
||||||
const cached = loginClients.get(base);
|
const cached = loginClients.get(base);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
||||||
const signal = opts?.signal;
|
const signal = opts?.signal;
|
||||||
|
const scope = opts?.scope ?? STALWART_LOGIN_SCOPE;
|
||||||
|
|
||||||
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, signal);
|
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, scope, signal);
|
||||||
if (own === 'busy') return fallback;
|
if (own === 'busy') return fallback;
|
||||||
if (own === 'known') {
|
if (own === 'known') {
|
||||||
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
|
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
|
||||||
@@ -281,7 +308,7 @@ export async function resolveLoginClient(
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
if (own === 'unknown') {
|
if (own === 'unknown') {
|
||||||
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, signal);
|
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, scope, signal);
|
||||||
if (shared === 'busy') return fallback;
|
if (shared === 'busy') return fallback;
|
||||||
if (shared === 'unknown') {
|
if (shared === 'unknown') {
|
||||||
const endpoint = opts?.registrationEndpoint;
|
const endpoint = opts?.registrationEndpoint;
|
||||||
@@ -502,10 +529,15 @@ export async function exchangePasswordForTokens(
|
|||||||
: await resolveLoginClient(base, { signal });
|
: await resolveLoginClient(base, { signal });
|
||||||
let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
|
let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
|
||||||
// A remembered client the server no longer knows (another server now
|
// A remembered client the server no longer knows (another server now
|
||||||
// answers at the address, or its key changed), or the default taken while
|
// answers at the address, or its key changed) or no longer gives the
|
||||||
// the client check failed: ask again, once. 1.0 checks the client before
|
// scopes (bulwark-mobile after the upgrade to 1.0.3), or the default taken
|
||||||
// the credentials, so this costs no failed sign-in.
|
// while the client check failed: ask again, once. 1.0 checks the client
|
||||||
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) {
|
// before the credentials, so this costs no failed sign-in.
|
||||||
|
if (
|
||||||
|
outcome.kind === 'rejected'
|
||||||
|
&& (outcome.error === 'invalid_client' || outcome.error === 'invalid_scope')
|
||||||
|
&& !opts?.clientId
|
||||||
|
) {
|
||||||
loginClients.delete(base);
|
loginClients.delete(base);
|
||||||
const fresh = await resolveLoginClient(base, { signal });
|
const fresh = await resolveLoginClient(base, { signal });
|
||||||
if (fresh.clientId !== client.clientId) {
|
if (fresh.clientId !== client.clientId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user