feat(server): passkey pre-refactor (#15060)

#### PR Dependency Tree


* **PR #15060** 👈

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**
* OpenApp native sign-in and native session exchange (JWT) for mobile &
desktop.
  * Centralized short-lived auth challenge store for one-time tokens.
* Encrypted per-endpoint token storage and native token handlers
(Android, iOS, Electron).

* **Improvements**
* Richer auth-method reporting (password, magic link, OAuth, passkey)
and improved sign-in flows.
* Hardened magic-link, OAuth, and session issuance; JWT-backed sessions
and websocket JWT support.
* UX tweaks: form-based password submit, OTP autocomplete, adjusted
captcha flow.

* **Bug Fixes**
  * Expanded tests and auth-state resets to avoid cross-test leakage.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -41,7 +41,6 @@ describe('AuthService oauthPreflight', () => {
framework.service(NbstoreService, {
realtime: { subscribe: () => of() },
} as any);
framework.service(AuthService, [
FetchService,
AuthStore,
@@ -16,6 +16,7 @@ export const Captcha = () => {
const handleTurnstileSuccess = useCallback(
(token: string) => {
captchaService.challenge$.next(undefined);
captchaService.verifyToken$.next(token);
},
[captchaService]
@@ -86,7 +86,6 @@ export const SignInWithEmailStep = ({
setIsSending(true);
try {
setResendCountDown(60);
captchaService.revalidate();
await authService.sendEmailMagicLink(
email,
verifyToken,
@@ -100,6 +99,7 @@ export const SignInWithEmailStep = ({
title: 'Failed to sign in',
message: t[`error.${error.name}`](error.data),
});
captchaService.revalidate();
}
setIsSending(false);
}, [
@@ -182,6 +182,7 @@ export const SignInWithEmailStep = ({
errorHint={otpError}
onEnter={onContinue}
type="text"
autoComplete="one-time-code"
required={true}
maxLength={6}
/>
@@ -85,7 +85,6 @@ export const SignInWithPasswordStep = ({
setIsLoading(true);
try {
captchaService.revalidate();
await authService.signInPassword({
email,
password,
@@ -111,6 +110,7 @@ export const SignInWithPasswordStep = ({
: t[`error.${error.name}`](error.data),
});
}
captchaService.revalidate();
} finally {
setIsLoading(false);
}
@@ -138,28 +138,50 @@ export const SignInWithPasswordStep = ({
/>
<AuthContent>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
<AuthInput
autoFocus
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
onChange={(value: string) => {
setPassword(value);
if (passwordError) {
setPasswordError(false);
setPasswordErrorHint(t['com.affine.auth.password.error']());
}
<form
onSubmit={event => {
event.preventDefault();
onSignIn();
}}
error={passwordError}
errorHint={passwordErrorHint}
onEnter={onSignIn}
/>
>
<AuthInput
label={t['com.affine.settings.email']()}
readOnly={true}
value={email}
type="email"
name="username"
autoComplete="username"
/>
<AuthInput
autoFocus
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
name="password"
autoComplete="current-password"
onChange={(value: string) => {
setPassword(value);
if (passwordError) {
setPasswordError(false);
setPasswordErrorHint(t['com.affine.auth.password.error']());
}
}}
error={passwordError}
errorHint={passwordErrorHint}
onEnter={onSignIn}
/>
{!verifyToken && needCaptcha && <Captcha />}
<Button
data-testid="sign-in-button"
variant="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={isLoading || (!verifyToken && needCaptcha)}
>
{t['com.affine.auth.sign.in']()}
</Button>
</form>
{!isSelfhosted && (
<div className={styles.passwordButtonRow}>
<a
@@ -171,17 +193,6 @@ export const SignInWithPasswordStep = ({
</a>
</div>
)}
{!verifyToken && needCaptcha && <Captcha />}
<Button
data-testid="sign-in-button"
variant="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={isLoading || (!verifyToken && needCaptcha)}
onClick={onSignIn}
>
{t['com.affine.auth.sign.in']()}
</Button>
</AuthContent>
<AuthFooter>
<Back changeState={changeState} />
@@ -90,7 +90,9 @@ export const SignInStep = ({
setIsMutating(true);
try {
const { hasPassword } = await authService.checkUserByEmail(email);
const { methods } = await authService.checkUserByEmail(email);
const hasPassword = methods.password.available;
const canUseMagicLink = methods.magicLink.available;
if (hasPassword) {
changeState(prev => ({
@@ -99,13 +101,18 @@ export const SignInStep = ({
step: 'signInWithPassword',
hasPassword: true,
}));
} else {
} else if (canUseMagicLink) {
changeState(prev => ({
...prev,
email,
step: 'signInWithEmail',
hasPassword: false,
}));
} else {
notify.error({
title: 'Failed to sign in',
message: 'This email is not available for sign in.',
});
}
} catch (err: any) {
console.error(err);
@@ -151,31 +158,41 @@ export const SignInStep = ({
<AuthContent>
<OAuth redirectUrl={state.redirectUrl} />
<AuthInput
className={style.authInput}
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
<Button
className={style.signInButton}
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
onClick={onContinue}
<form
onSubmit={event => {
event.preventDefault();
onContinue();
}}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
<AuthInput
className={style.authInput}
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
onChange={setEmail}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
type="email"
name="username"
autoComplete="username"
/>
<Button
className={style.signInButton}
style={{ width: '100%' }}
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
disabled={isMutating}
suffix={<ArrowRightBigIcon />}
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
</form>
{!isSelfhosted && (
<>
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
export const ChangePasswordDialog = ({
close,
hasPassword: hasPasswordProp,
server: serverBaseUrl,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['change-password']>) => {
const t = useI18n();
@@ -44,7 +45,8 @@ export const ChangePasswordDialog = ({
const authService = server.scope.get(AuthService);
const account = useLiveData(authService.session.account$);
const email = account?.email;
const hasPassword = account?.info?.hasPassword;
const hasPassword =
hasPasswordProp ?? account?.info?.authMethods?.password.bound ?? false;
const [hasSentEmail, setHasSentEmail] = useState(false);
const [loading, setLoading] = useState(false);
const passwordLimits = useLiveData(
@@ -201,13 +201,19 @@ export const AccountSetting = ({
const onPasswordButtonClick = useCallback(() => {
globalDialogService.open('change-password', {
hasPassword: account?.info?.authMethods?.password.bound,
server: serverService.server.baseUrl,
});
}, [globalDialogService, serverService.server.baseUrl]);
}, [
account?.info?.authMethods?.password.bound,
globalDialogService,
serverService.server.baseUrl,
]);
if (!account) {
return null;
}
const hasPassword = account.info?.authMethods?.password.bound;
return (
<>
@@ -233,7 +239,7 @@ export const AccountSetting = ({
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onPasswordButtonClick}>
{account.info?.hasPassword
{hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
@@ -79,6 +79,13 @@ export function configureDefaultAuthProvider(framework: Framework) {
},
});
},
async signInOpenAppSignInCode(code: string) {
await fetchService.fetch('/api/auth/open-app/sign-in', {
method: 'POST',
body: JSON.stringify({ code }),
headers: { 'content-type': 'application/json' },
});
},
async signOut() {
const csrfToken = getCookieValue(CSRF_COOKIE_NAME);
await fetchService.fetch('/api/auth/sign-out', {
@@ -21,6 +21,8 @@ export interface AuthProvider {
challenge?: string;
}): Promise<void>;
signInOpenAppSignInCode(code: string): Promise<void>;
signOut(): Promise<void>;
}
@@ -222,11 +222,7 @@ export class AuthService extends Service {
}
async signInOpenAppSignInCode(code: string) {
await this.fetchService.fetch('/api/auth/open-app/sign-in', {
method: 'POST',
body: JSON.stringify({ code }),
headers: { 'content-type': 'application/json' },
});
await this.store.signInOpenAppSignInCode(code);
this.session.revalidate();
}
@@ -33,7 +33,7 @@ export class CaptchaService extends Service {
revalidate = effect(
exhaustMap(() => {
return fromPromise(async signal => {
if (!this.needCaptcha$.value) {
if (!this.needCaptcha$.value || !this.validatorProvider) {
return {};
}
const res = await this.fetchService.fetch('/api/auth/challenge', {
@@ -46,17 +46,14 @@ export class CaptchaService extends Service {
if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge');
}
if (this.validatorProvider) {
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
token,
challenge: data.challenge,
};
}
return { challenge: data.challenge, token: undefined };
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
token,
challenge: data.challenge,
};
}).pipe(
tap(({ challenge, token }) => {
this.verifyToken$.next(token);
@@ -19,6 +19,11 @@ export interface AccountProfile {
email: string;
name: string;
hasPassword: boolean;
authMethods?: {
password: { bound: boolean };
oauth: { bound: boolean; providers: string[] };
passkey: { bound: boolean; count: number };
};
avatarUrl: string | null;
emailVerified: string | null;
features?: string[];
@@ -61,16 +66,20 @@ export class AuthStore extends Store {
}
async fetchSession() {
const { user } = await this.nbstoreService.realtime.request(
'user.profile.get',
{},
{ timeoutMs: 10000 }
);
const { user } = await this.fetchService
.fetch('/api/auth/session')
.then(res => res.json());
const authMethods = user
? await this.fetchService
.fetch('/api/auth/methods')
.then(res => (res.ok ? res.json() : undefined))
: undefined;
return {
user: user
? {
...user,
hasPassword: Boolean(user.hasPassword),
authMethods,
emailVerified: user.emailVerified ? 'true' : null,
}
: null,
@@ -103,6 +112,10 @@ export class AuthStore extends Store {
await this.authProvider.signInPassword(credential);
}
async signInOpenAppSignInCode(code: string) {
await this.authProvider.signInOpenAppSignInCode(code);
}
async signOut() {
await this.authProvider.signOut();
await this.nbstoreService.realtime.configure({
@@ -155,8 +168,12 @@ export class AuthStore extends Store {
const data = (await res.json()) as {
registered: boolean;
hasPassword: boolean;
magicLink: boolean;
methods: {
password: { available: boolean };
magicLink: { available: boolean };
oauth: { available: boolean; providers: string[] };
passkey: { available: boolean; discoverable: boolean };
};
};
return data;
@@ -30,7 +30,10 @@ export type GLOBAL_DIALOG_SCHEMA = {
snapshotUrl: string;
}) => void;
'sign-in': (props: { server?: string; step?: string }) => void;
'change-password': (props: { server?: string }) => void;
'change-password': (props: {
server?: string;
hasPassword?: boolean;
}) => void;
'verify-email': (props: { server?: string; changeEmail?: boolean }) => void;
'enable-cloud': (props: {
workspaceId: string;