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
@@ -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;