Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions packages/wallet-sdk/src/domains/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, expect, mock, test } from 'bun:test';
import type { GuestAccountStorage } from '../internal/guest-account-storage';
import type { OpenSecretClient } from '../internal/open-secret';
import type { SessionResolver } from '../internal/session';
import type { User } from '../types/user';
import { AuthDomainImpl } from './auth';

/** A signed-in user fixture returned by the (faked) session resolver. */
const signedInUser = { id: 'user-1', username: 'alice' } as unknown as User;

/** A `Promise<void>`-returning mock body (non-empty, to satisfy the no-empty-block lint). */
const resolved = () => Promise.resolve();

/**
* Build an {@link AuthDomainImpl} over mocked collaborators, exposing the mocks so a test
* can assert calls. `storedGuest` seeds the guest-credential store.
*/
function makeAuth(storedGuest: { id: string; password: string } | null = null) {
const openSecret = {
signIn: mock(resolved),
signUp: mock(resolved),
signUpGuest: mock(() => Promise.resolve({ id: 'guest-new' })),
signInGuest: mock(resolved),
signOut: mock(resolved),
refresh: mock(resolved),
convertGuestToUserAccount: mock(resolved),
changePassword: mock(resolved),
requestPasswordReset: mock(resolved),
initiateGoogleAuth: mock(() =>
Promise.resolve({ authUrl: 'https://auth/url' }),
),
handleGoogleCallback: mock(resolved),
} as unknown as OpenSecretClient;

const session = {
completeSignIn: mock(() => Promise.resolve(signedInUser)),
completeSignOut: mock(() => undefined),
} as unknown as SessionResolver;

const guestStorage = {
get: mock(() => Promise.resolve(storedGuest)),
store: mock(resolved),
clear: mock(resolved),
} as unknown as GuestAccountStorage;

const auth = new AuthDomainImpl(openSecret, session, guestStorage);
return { auth, openSecret, session, guestStorage };
}

describe('AuthDomainImpl.signIn', () => {
test('calls OpenSecret.signIn then resolves + returns the user', async () => {
const { auth, openSecret, session } = makeAuth();

const user = await auth.signIn({
email: 'a@b.com',
password: 'pw',
});

expect(openSecret.signIn).toHaveBeenCalledWith('a@b.com', 'pw');
expect(session.completeSignIn).toHaveBeenCalledTimes(1);
expect(user).toBe(signedInUser);
});
});

describe('AuthDomainImpl.signInGuest', () => {
test('with no stored guest: creates one, stores creds, completes sign-in', async () => {
const { auth, openSecret, guestStorage } = makeAuth(null);

await auth.signInGuest();

expect(openSecret.signUpGuest).toHaveBeenCalledTimes(1);
// signInGuest (re-sign-in path) must NOT be used when minting a fresh guest
expect(openSecret.signInGuest).not.toHaveBeenCalled();
expect(guestStorage.store).toHaveBeenCalledTimes(1);
const stored = (guestStorage.store as ReturnType<typeof mock>).mock
.calls[0][0];
expect(stored.id).toBe('guest-new');
expect(typeof stored.password).toBe('string');
});

test('with a stored guest: re-signs into it, does not mint a new one', async () => {
const { auth, openSecret, guestStorage } = makeAuth({
id: 'guest-existing',
password: 'secret',
});

await auth.signInGuest();

expect(openSecret.signInGuest).toHaveBeenCalledWith(
'guest-existing',
'secret',
);
expect(openSecret.signUpGuest).not.toHaveBeenCalled();
expect(guestStorage.store).not.toHaveBeenCalled();
});
});

describe('AuthDomainImpl.upgradeGuest', () => {
test('converts the account, clears guest creds, returns the user', async () => {
const { auth, openSecret, guestStorage } = makeAuth();

const user = await auth.upgradeGuest({
email: 'a@b.com',
password: 'pw',
});

expect(openSecret.convertGuestToUserAccount).toHaveBeenCalledWith(
'a@b.com',
'pw',
);
expect(guestStorage.clear).toHaveBeenCalledTimes(1);
expect(user).toBe(signedInUser);
});
});

describe('AuthDomainImpl.signOut', () => {
test('signs out of the enclave then completes the local sign-out', async () => {
const { auth, openSecret, session } = makeAuth();

await auth.signOut();

expect(openSecret.signOut).toHaveBeenCalledTimes(1);
expect(session.completeSignOut).toHaveBeenCalledTimes(1);
});
});

describe('AuthDomainImpl.resetPassword', () => {
test('sends a hashed secret (not the plaintext email) to OpenSecret', async () => {
const { auth, openSecret } = makeAuth();

await auth.resetPassword('a@b.com');

expect(openSecret.requestPasswordReset).toHaveBeenCalledTimes(1);
const [email, hashedSecret] = (
openSecret.requestPasswordReset as ReturnType<typeof mock>
).mock.calls[0];
expect(email).toBe('a@b.com');
// a SHA-256 hex digest of the generated secret
expect(hashedSecret).toMatch(/^[0-9a-f]{64}$/);
});
});

describe('AuthDomainImpl.changePassword', () => {
test('passes current + new passwords through to the enclave', async () => {
const { auth, openSecret } = makeAuth();

await auth.changePassword({ current: 'old', new: 'new' });

expect(openSecret.changePassword).toHaveBeenCalledWith('old', 'new');
});
});

describe('AuthDomainImpl.beginGoogleSignIn', () => {
test('returns the enclave auth URL', async () => {
const { auth } = makeAuth();
expect(await auth.beginGoogleSignIn()).toEqual({
authUrl: 'https://auth/url',
});
});
});

describe('AuthDomainImpl.completeOAuth', () => {
test('forwards code/state/inviteCode and resolves the user', async () => {
const { auth, openSecret, session } = makeAuth();

const user = await auth.completeOAuth({
code: 'c',
state: 's',
inviteCode: 'inv',
});

expect(openSecret.handleGoogleCallback).toHaveBeenCalledWith(
'c',
's',
'inv',
);
expect(session.completeSignIn).toHaveBeenCalledTimes(1);
expect(user).toBe(signedInUser);
});

test("defaults inviteCode to '' when omitted", async () => {
const { auth, openSecret } = makeAuth();

await auth.completeOAuth({ code: 'c', state: 's' });

expect(openSecret.handleGoogleCallback).toHaveBeenCalledWith('c', 's', '');
});
});
202 changes: 202 additions & 0 deletions packages/wallet-sdk/src/domains/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* `AuthDomain` implementation — §4 of the contract, Slice 1.
*
* EXTRACTED (re-housed framework-free) from `apps/web-wallet/app/features/user/auth.ts`
* (the `useAuthActions` hook + the OpenSecret-SDK wrappers) and
* `apps/web-wallet/app/features/shared/auth.ts`. Master expresses auth as a React hook
* over TanStack-Query invalidation + `react-router` navigation + `window.localStorage`;
* all of that is stripped. What remains is the enclave calls + the agicash `User`
* resolution + the SDK's `auth:*` events:
*
* - sign-in/up/guest/upgrade/OAuth-complete call OpenSecret, then resolve the
* `wallet.users` DB row (via {@link SessionResolver.completeSignIn}) and return the
* domain {@link User}, emitting `auth:signed-in`;
* - sign-out calls OpenSecret then emits `auth:signed-out`;
* - refresh / changePassword / resetPassword are thin enclave pass-throughs that master
* expresses implicitly via the OS SDK (the contract names them explicitly).
*
* No `getCurrentSession` method (contract decision 4 — methods return `User`, not a
* session; the JWT stays SDK-internal). OAuth is a browser REDIRECT (`beginGoogleSignIn`
* returns `{ authUrl }`), web-only.
*
* @module
*/
import { computeSHA256, generateRandomPassword } from '../internal/crypto';
import type { GuestAccountStorage } from '../internal/guest-account-storage';
import type { OpenSecretClient } from '../internal/open-secret';
import type { SessionResolver } from '../internal/session';
import type { AuthDomain } from '../domains';
import type { User } from '../types/user';

/** Length of a generated guest-account password (master `generateRandomPassword(32)`). */
const GUEST_PASSWORD_LENGTH = 32;
/** Length of the generated password-reset secret (master `generateRandomPassword(20)`). */
const RESET_SECRET_LENGTH = 20;

/** OAuth callback params handed to {@link AuthDomainImpl.completeOAuth}. */
type OAuthCallbackParams = {
/** OAuth authorization code from the redirect. */
code: string;
/** OAuth `state` param from the redirect (CSRF + session correlation). */
state: string;
/** Optional invite code for new-user registration (defaults to `''`, as master does). */
inviteCode?: string;
};

/**
* The auth domain. Construct with the enclave client, the session resolver (id → DB user
* + `auth:*` emission), and the guest-credential store.
*/
export class AuthDomainImpl implements AuthDomain {
/**
* @param openSecret - the OpenSecret enclave client.
* @param session - resolves the agicash user + emits `auth:*`.
* @param guestStorage - persists guest credentials for same-device re-sign-in.
*/
constructor(
private readonly openSecret: OpenSecretClient,
private readonly session: SessionResolver,
private readonly guestStorage: GuestAccountStorage,
) {}

/**
* Sign in an existing user with email + password.
*
* @param params - `{ email, password }`.
* @returns the signed-in {@link User}.
*/
async signIn(params: { email: string; password: string }): Promise<User> {
await this.openSecret.signIn(params.email, params.password);
return this.session.completeSignIn();
}

/**
* Create a new full (email) account and sign it in.
*
* @param params - `{ email, password }`.
* @returns the new {@link User}.
*/
async signUp(params: { email: string; password: string }): Promise<User> {
await this.openSecret.signUp(params.email, params.password);
return this.session.completeSignIn();
}

/**
* Create and sign in an anonymous guest user. If this device already has a stored guest
* account, signs back into THAT account instead of minting a new one (master parity:
* `useAuthActions.signUpGuest`). The generated password is persisted via the storage
* adapter.
*
* @returns the guest {@link User}.
*/
async signInGuest(): Promise<User> {
const existing = await this.guestStorage.get();
if (existing) {
await this.openSecret.signInGuest(existing.id, existing.password);
return this.session.completeSignIn();
}

const password = generateRandomPassword(GUEST_PASSWORD_LENGTH);
const { id } = await this.openSecret.signUpGuest(password);
await this.guestStorage.store({ id, password });
return this.session.completeSignIn();
}

/**
* Sign out the current user and clear the session.
*
* The OpenSecret SDK clears its own persisted tokens; this drops the cached Supabase
* token and emits `auth:signed-out`.
*/
async signOut(): Promise<void> {
await this.openSecret.signOut();
this.session.completeSignOut();
}

/**
* Refresh the current session/access token (extends the session). Master expresses this
* implicitly via the OS SDK; the contract names it explicitly.
*/
async refresh(): Promise<void> {
await this.openSecret.refresh();
}

/**
* Send a password-reset email to `email`.
*
* Re-houses master `useAuthActions.requestPasswordReset`: generate a random secret, send
* its SHA-256 hash to OpenSecret (the emailed code + this secret later confirm the reset
* via the enclave). The contract types this `Promise<void>`; the secret needed for the
* confirm step is held by OpenSecret's flow, so it is not surfaced here.
*
* @param email - the account email to reset.
*/
async resetPassword(email: string): Promise<void> {
const secret = generateRandomPassword(RESET_SECRET_LENGTH);
const hashedSecret = await computeSHA256(secret);
await this.openSecret.requestPasswordReset(email, hashedSecret);
}

/**
* Change the signed-in user's password (requires the current password).
*
* @param params - `{ current, new }` passwords.
*/
async changePassword(params: {
current: string;
new: string;
}): Promise<void> {
await this.openSecret.changePassword(params.current, params.new);
}

/**
* Upgrade the current guest user into a full email account, preserving funds/history.
* Clears the stored guest credentials on success (master parity:
* `useUpgradeGuestToFullAccount`).
*
* @param params - `{ email, password }` for the new full account.
* @returns the upgraded {@link User}.
*/
async upgradeGuest(params: {
email: string;
password: string;
}): Promise<User> {
await this.openSecret.convertGuestToUserAccount(
params.email,
params.password,
);
await this.guestStorage.clear();
return this.session.completeSignIn();
}

/**
* Begin Google OAuth — returns the URL to redirect the browser to. OAuth is a REDIRECT
* flow, not a synchronous session; web-only (the MCP daemon cannot do Google auth).
*
* NOTE: master also stashes the pre-redirect `location.search`/`hash` into an OAuth
* login-session store and folds a `sessionId` into the `state` param so the post-redirect
* page can restore context. That stashing is browser/router-specific UI plumbing and is
* left to the web consumer (the SDK is framework-free); the SDK returns the enclave's
* `authUrl` directly.
*
* @returns `{ authUrl }` to redirect to.
*/
async beginGoogleSignIn(): Promise<{ authUrl: string }> {
return this.openSecret.initiateGoogleAuth();
}

/**
* Complete OAuth from the redirect callback params; resolves with the user.
*
* @param params - `{ code, state, inviteCode? }` from the OAuth redirect.
* @returns the signed-in {@link User}.
*/
async completeOAuth(params: OAuthCallbackParams): Promise<User> {
await this.openSecret.handleGoogleCallback(
params.code,
params.state,
params.inviteCode ?? '',
);
return this.session.completeSignIn();
}
}
Loading
Loading