feat(core): improve login flow (#15219)

#### PR Dependency Tree


* **PR #15219** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added secure, automatic auth session token refresh and request replay
for expired-token responses across Android, iOS, and Electron.
* Updated sign-in flows to manage sessions without returning tokens to
the app layer.
* Added “Devices” management UI with sign out per device and sign out
all.
  * Enabled support for both Hashcash and Turnstile captcha providers.
* **Bug Fixes**
* Improved refresh de-duplication, inflight cancellation/clear behavior,
and recovery from corrupted/invalid sessions.
* **Tests**
* Expanded auth-session, refresh/revoke, and replay coverage (Electron
unit tests, Android instrumentation tests, iOS auth date parser tests).
* **Chores**
* Removed CAPTCHA site key from build-time configuration and adjusted CI
test execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-12 18:11:02 +08:00
committed by GitHub
parent 02b25e05d8
commit abf37d3dfa
57 changed files with 2919 additions and 789 deletions
@@ -19,21 +19,50 @@ import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
let authTokenPort: MessagePort | undefined;
const pendingTokenRequests = new Map<string, (token: string | null) => void>();
const pendingTokenRequests = new Map<
string,
{
resolve: (token: string | null) => void;
reject: (error: Error) => void;
}
>();
configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint)
getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({}));
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'native-auth-token-channel') {
if (e.data.type === 'auth-access-token-channel') {
authTokenPort = e.ports[0] as MessagePort;
authTokenPort.addEventListener('message', e => {
const { id, token } = e.data as { id?: string; token?: string | null };
const { id, token, error } = e.data as {
id?: string;
token?: string | null;
error?: string;
};
if (!id) return;
pendingTokenRequests.get(id)?.(token ?? null);
const pending = pendingTokenRequests.get(id);
if (error) {
if (
[
'ACCESS_TOKEN_INVALID',
'AUTH_SESSION_EXPIRED',
'AUTH_SESSION_REVOKED',
'REFRESH_TOKEN_INVALID',
'REFRESH_TOKEN_REUSED',
'UNSUPPORTED_CLIENT_VERSION',
'AUTH_SESSION_EMPTY',
].includes(error)
) {
pending?.resolve(null);
} else {
pending?.reject(new Error(error));
}
} else {
pending?.resolve(token ?? null);
}
pendingTokenRequests.delete(id);
});
authTokenPort.start();
@@ -66,20 +95,26 @@ globalThis.addEventListener('message', e => {
}
});
function readEndpointToken(endpoint: string) {
function getValidAccessToken(endpoint: string) {
if (!authTokenPort) {
return Promise.resolve(null);
}
const id = `${Date.now()}:${Math.random()}`;
return new Promise<string | null>(resolve => {
return new Promise<string | null>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingTokenRequests.delete(id);
resolve(null);
reject(new Error('AUTH_SESSION_TEMPORARILY_UNAVAILABLE'));
}, 5000);
pendingTokenRequests.set(id, token => {
clearTimeout(timeout);
resolve(token);
pendingTokenRequests.set(id, {
resolve: token => {
clearTimeout(timeout);
resolve(token);
},
reject: error => {
clearTimeout(timeout);
reject(error);
},
});
authTokenPort?.postMessage({ id, endpoint });
});