mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-11 22:18:54 +08:00
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:
@@ -78,11 +78,7 @@ import { ImagePicker } from './plugins/image-picker';
|
||||
import { NbStoreNativeDBApis } from './plugins/nbstore';
|
||||
import { PayWall } from './plugins/paywall';
|
||||
import { Preview } from './plugins/preview';
|
||||
import {
|
||||
deleteEndpointToken,
|
||||
readEndpointToken,
|
||||
writeEndpointToken,
|
||||
} from './proxy';
|
||||
import { clearEndpointSession, getValidAccessToken } from './proxy';
|
||||
import { enableNavigationGesture$ } from './web-navigation-control';
|
||||
|
||||
const storeManagerClient = createStoreManagerClient();
|
||||
@@ -187,46 +183,44 @@ framework.scope(ServerScope).override(AuthProvider, resolver => {
|
||||
const endpoint = serverService.server.baseUrl;
|
||||
return {
|
||||
async signInMagicLink(email, linkToken, clientNonce) {
|
||||
const { token } = await Auth.signInMagicLink({
|
||||
await Auth.signInMagicLink({
|
||||
endpoint,
|
||||
email,
|
||||
token: linkToken,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signInOauth(code, state, _provider, clientNonce) {
|
||||
const { token } = await Auth.signInOauth({
|
||||
await Auth.signInOauth({
|
||||
endpoint,
|
||||
code,
|
||||
state,
|
||||
clientNonce,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
return {};
|
||||
},
|
||||
async signInPassword(credential) {
|
||||
const { token } = await Auth.signInPassword({
|
||||
await Auth.signInPassword({
|
||||
endpoint,
|
||||
...credential,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signInOpenAppSignInCode(code) {
|
||||
const { token } = await Auth.signInOpenApp({
|
||||
await Auth.signInOpenApp({
|
||||
endpoint,
|
||||
code,
|
||||
});
|
||||
await writeEndpointToken(endpoint, token);
|
||||
},
|
||||
async signOut() {
|
||||
const token = await readEndpointToken(endpoint);
|
||||
try {
|
||||
await Auth.signOut({ endpoint, token });
|
||||
await Auth.signOut({ endpoint });
|
||||
} finally {
|
||||
await deleteEndpointToken(endpoint);
|
||||
await clearEndpointSession(endpoint);
|
||||
}
|
||||
},
|
||||
async clearSession() {
|
||||
await clearEndpointSession(endpoint);
|
||||
},
|
||||
};
|
||||
});
|
||||
framework.impl(NativePaywallProvider, {
|
||||
@@ -463,6 +457,13 @@ window.addEventListener('focus', () => {
|
||||
frameworkProvider.get(LifecycleService).applicationFocus();
|
||||
});
|
||||
frameworkProvider.get(LifecycleService).applicationStart();
|
||||
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
|
||||
if (!isActive) return;
|
||||
const servers = frameworkProvider.get(ServersService).servers$.value;
|
||||
Promise.allSettled(
|
||||
servers.map(server => getValidAccessToken(server.baseUrl))
|
||||
).catch(console.error);
|
||||
}).catch(console.error);
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (typeof error === 'string' && error) {
|
||||
@@ -628,13 +629,21 @@ function createStoreManagerClient() {
|
||||
authTokenChannelServer.addEventListener('message', event => {
|
||||
const { id, endpoint } = event.data as { id?: string; endpoint?: string };
|
||||
if (!id || !endpoint) return;
|
||||
readEndpointToken(endpoint)
|
||||
getValidAccessToken(endpoint)
|
||||
.then(token => authTokenChannelServer.postMessage({ id, token }))
|
||||
.catch(() => authTokenChannelServer.postMessage({ id, token: null }));
|
||||
.catch(error =>
|
||||
authTokenChannelServer.postMessage({
|
||||
id,
|
||||
error:
|
||||
typeof error === 'object' && error && 'code' in error
|
||||
? error.code
|
||||
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
||||
})
|
||||
);
|
||||
});
|
||||
authTokenChannelServer.start();
|
||||
worker.postMessage(
|
||||
{ type: 'native-auth-token-channel', port: authTokenChannelClient },
|
||||
{ type: 'auth-access-token-channel', port: authTokenChannelClient },
|
||||
[authTokenChannelClient]
|
||||
);
|
||||
return new StoreManagerClient(new OpClient(worker));
|
||||
|
||||
@@ -19,21 +19,49 @@ 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 terminalAuthErrors = new Set([
|
||||
'ACCESS_TOKEN_INVALID',
|
||||
'AUTH_SESSION_EXPIRED',
|
||||
'AUTH_SESSION_REVOKED',
|
||||
'REFRESH_TOKEN_INVALID',
|
||||
'REFRESH_TOKEN_REUSED',
|
||||
'UNSUPPORTED_CLIENT_VERSION',
|
||||
'AUTH_SESSION_EMPTY',
|
||||
]);
|
||||
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 (terminalAuthErrors.has(error)) {
|
||||
pending?.resolve(null);
|
||||
} else {
|
||||
pending?.reject(new Error(error));
|
||||
}
|
||||
} else {
|
||||
pending?.resolve(token ?? null);
|
||||
}
|
||||
pendingTokenRequests.delete(id);
|
||||
});
|
||||
authTokenPort.start();
|
||||
@@ -66,20 +94,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 });
|
||||
});
|
||||
|
||||
@@ -4,31 +4,25 @@ export interface AuthPlugin {
|
||||
email: string;
|
||||
token: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
}): Promise<void>;
|
||||
signInOauth(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
state: string;
|
||||
clientNonce?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
}): Promise<void>;
|
||||
signInPassword(options: {
|
||||
endpoint: string;
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signInOpenApp(options: {
|
||||
endpoint: string;
|
||||
code: string;
|
||||
}): Promise<{ token: string }>;
|
||||
signOut(options: { endpoint: string; token?: string | null }): Promise<void>;
|
||||
readEndpointToken(options: {
|
||||
}): Promise<void>;
|
||||
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
|
||||
signOut(options: { endpoint: string }): Promise<void>;
|
||||
getValidAccessToken(options: {
|
||||
endpoint: string;
|
||||
}): Promise<{ token?: string | null }>;
|
||||
writeEndpointToken(options: {
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}): Promise<void>;
|
||||
deleteEndpointToken(options: { endpoint: string }): Promise<void>;
|
||||
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
|
||||
clearEndpointSession(options: { endpoint: string }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
|
||||
|
||||
import { Auth } from './plugins/auth';
|
||||
|
||||
function authEndpointForUrl(url: string | URL) {
|
||||
@@ -11,10 +13,6 @@ function authEndpointForUrl(url: string | URL) {
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEndpoint(endpoint: string) {
|
||||
return authEndpointForUrl(endpoint) ?? endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
|
||||
* should be included in the entry file of the app or webworker.
|
||||
@@ -22,22 +20,84 @@ function canonicalEndpoint(endpoint: string) {
|
||||
const rawFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init);
|
||||
const retry = request.clone();
|
||||
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
|
||||
const token = origin
|
||||
? await readEndpointToken(origin).catch(() => null)
|
||||
: null;
|
||||
const token = origin ? await getValidAccessToken(origin) : null;
|
||||
if (token) {
|
||||
request.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return rawFetch(request);
|
||||
const response = await rawFetch(request);
|
||||
if (response.status !== 401 || !origin) return response;
|
||||
const body = await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
|
||||
const { token: refreshed } = await Auth.refreshAccessToken({
|
||||
endpoint: origin,
|
||||
});
|
||||
retry.headers.set('Authorization', `Bearer ${refreshed}`);
|
||||
return rawFetch(retry);
|
||||
};
|
||||
|
||||
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
|
||||
const xhrRequestUrls = new WeakMap<XMLHttpRequest, string>();
|
||||
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
private request:
|
||||
| {
|
||||
method: string;
|
||||
url: string | URL;
|
||||
async: boolean;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
private readonly headers = new Map<string, string>();
|
||||
private requestBody?: Document | XMLHttpRequestBodyInit | null;
|
||||
private replaying = false;
|
||||
private hasReplayed = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const suppressExpiredResponse = (event: Event) => {
|
||||
if (this.replaying) event.stopImmediatePropagation();
|
||||
};
|
||||
this.addEventListener('load', suppressExpiredResponse, true);
|
||||
this.addEventListener('loadend', suppressExpiredResponse, true);
|
||||
this.addEventListener(
|
||||
'readystatechange',
|
||||
event => {
|
||||
if (
|
||||
this.readyState !== rawXMLHttpRequest.DONE ||
|
||||
this.status !== 401 ||
|
||||
this.replaying ||
|
||||
this.hasReplayed ||
|
||||
!this.request?.async
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let code: unknown;
|
||||
try {
|
||||
code =
|
||||
this.responseType === 'json'
|
||||
? this.response?.code
|
||||
: JSON.parse(this.responseText)?.code;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (code !== 'ACCESS_TOKEN_EXPIRED') return;
|
||||
event.stopImmediatePropagation();
|
||||
this.replaying = true;
|
||||
this.hasReplayed = true;
|
||||
this.replayWithFreshToken().catch(() => {});
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
override open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
@@ -45,6 +105,11 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
username?: string | null,
|
||||
password?: string | null
|
||||
): void {
|
||||
this.request = { method, url, async, username, password };
|
||||
this.headers.clear();
|
||||
this.requestBody = undefined;
|
||||
this.replaying = false;
|
||||
this.hasReplayed = false;
|
||||
xhrRequestUrls.set(this, url.toString());
|
||||
return super.open(
|
||||
method,
|
||||
@@ -55,40 +120,81 @@ globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
|
||||
);
|
||||
}
|
||||
|
||||
override setRequestHeader(name: string, value: string): void {
|
||||
this.headers.set(name, value);
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
|
||||
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
this.requestBody = body;
|
||||
const requestUrl = xhrRequestUrls.get(this);
|
||||
const origin = authEndpointForUrl(requestUrl ?? globalThis.location.href);
|
||||
|
||||
(origin ? readEndpointToken(origin) : Promise.resolve(null)).then(
|
||||
token => {
|
||||
(origin ? getValidAccessToken(origin) : Promise.resolve(null))
|
||||
.then(token => {
|
||||
if (token) {
|
||||
this.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return super.send(body);
|
||||
},
|
||||
() => {
|
||||
return super.send(body);
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
});
|
||||
}
|
||||
|
||||
private async replayWithFreshToken() {
|
||||
const request = this.request;
|
||||
if (!request) return this.failReplay();
|
||||
const origin = authEndpointForUrl(request.url);
|
||||
if (!origin) return this.failReplay();
|
||||
try {
|
||||
const { token } = await Auth.refreshAccessToken({ endpoint: origin });
|
||||
const responseType = this.responseType;
|
||||
const timeout = this.timeout;
|
||||
const withCredentials = this.withCredentials;
|
||||
super.open(
|
||||
request.method,
|
||||
request.url,
|
||||
true,
|
||||
request.username ?? undefined,
|
||||
request.password ?? undefined
|
||||
);
|
||||
this.replaying = false;
|
||||
this.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase() !== 'authorization') {
|
||||
super.setRequestHeader(name, value);
|
||||
}
|
||||
});
|
||||
super.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
this.responseType = responseType;
|
||||
this.timeout = timeout;
|
||||
this.withCredentials = withCredentials;
|
||||
super.send(this.requestBody);
|
||||
} catch {
|
||||
this.failReplay();
|
||||
}
|
||||
}
|
||||
|
||||
private failReplay() {
|
||||
this.replaying = false;
|
||||
this.dispatchEvent(new Event('readystatechange'));
|
||||
this.dispatchEvent(new Event('error'));
|
||||
this.dispatchEvent(new Event('loadend'));
|
||||
}
|
||||
};
|
||||
|
||||
export async function readEndpointToken(
|
||||
export async function getValidAccessToken(
|
||||
endpoint: string
|
||||
): Promise<string | null> {
|
||||
const { token } = await Auth.readEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
const { token } = await Auth.getValidAccessToken({
|
||||
endpoint: canonicalAuthEndpoint(endpoint),
|
||||
});
|
||||
return token ?? null;
|
||||
}
|
||||
|
||||
export async function writeEndpointToken(endpoint: string, token: string) {
|
||||
await Auth.writeEndpointToken({
|
||||
endpoint: canonicalEndpoint(endpoint),
|
||||
token,
|
||||
export async function clearEndpointSession(endpoint: string) {
|
||||
await Auth.clearEndpointSession({
|
||||
endpoint: canonicalAuthEndpoint(endpoint),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEndpointToken(endpoint: string) {
|
||||
await Auth.deleteEndpointToken({ endpoint: canonicalEndpoint(endpoint) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user