From 289cf4972f6677372a64227818a70a56784c1d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20=C3=87al=C4=B1=C5=9Fkan?= Date: Thu, 16 Jul 2026 17:59:52 +0300 Subject: [PATCH 1/2] feat(card): switch Baanx login to OAuth code --- .../card-onboarding-address.test.tsx | 4 + .../src/__integration__/card-sign-in.test.tsx | 63 ++++++- .../__tests__/useCardSignInScreen.spec.ts | 23 +++ .../CardSignInScreen/useCardSignInScreen.ts | 74 ++++++-- .../__tests__/getValidIntegrityToken.test.ts | 54 ++++++ .../src/services/getValidIntegrityToken.ts | 31 ++++ packages/app-integrity/src/services/index.ts | 1 + packages/card/package.json | 2 + .../src/api/auth/__tests__/endpoints.test.ts | 121 ++++++++++++- .../api/auth/__tests__/oauth-login.test.ts | 149 ++++++++++++++++ .../card/src/api/auth/__tests__/pkce.test.ts | 46 +++++ .../api/auth/__tests__/transformers.test.ts | 25 ++- packages/card/src/api/auth/endpoints.ts | 164 +++++++++++++++++- packages/card/src/api/auth/index.ts | 20 +++ packages/card/src/api/auth/msw-handlers.ts | 126 ++++++++++++++ packages/card/src/api/auth/oauth-login.ts | 101 +++++++++++ packages/card/src/api/auth/pkce.ts | 53 ++++++ packages/card/src/api/auth/schema.ts | 34 +++- packages/card/src/api/auth/transformers.ts | 22 ++- .../__tests__/default-transport.test.ts | 116 ++++++++++++- .../api/transport/__tests__/registry.test.ts | 6 + .../src/api/transport/default-transport.ts | 33 +++- packages/card/src/api/transport/types.ts | 8 +- .../__tests__/onboardingMutations.test.ts | 44 ++++- .../__tests__/useCardLoginMutation.test.ts | 59 ++++++- .../__tests__/useSendLoginOtpMutation.test.ts | 75 ++++++++ packages/card/src/hooks/index.ts | 1 + .../card/src/hooks/useCardLoginMutation.ts | 54 ++++-- .../card/src/hooks/useSendLoginOtpMutation.ts | 40 +++++ .../src/hooks/useSubmitAddressMutation.ts | 16 +- packages/card/src/models/session.ts | 34 +++- .../src/session/__tests__/session.test.ts | 12 ++ packages/card/src/session/session.ts | 15 +- packages/card/src/test-handlers.ts | 15 +- .../hooks/__tests__/useFeeDelegation.spec.tsx | 39 ++--- .../src/hooks/useFeeDelegation.ts | 19 +- .../hooks/__tests__/usePasskeysQuery.spec.ts | 7 +- .../src/models/__tests__/passkey.spec.ts | 17 +- packages/passkeys/src/models/passkey.ts | 12 +- .../src/utils/__tests__/strings.test.ts | 28 ++- packages/shared/src/utils/strings.ts | 12 ++ pnpm-lock.yaml | 8 +- 42 files changed, 1628 insertions(+), 155 deletions(-) create mode 100644 packages/app-integrity/src/services/__tests__/getValidIntegrityToken.test.ts create mode 100644 packages/app-integrity/src/services/getValidIntegrityToken.ts create mode 100644 packages/card/src/api/auth/__tests__/oauth-login.test.ts create mode 100644 packages/card/src/api/auth/__tests__/pkce.test.ts create mode 100644 packages/card/src/api/auth/oauth-login.ts create mode 100644 packages/card/src/api/auth/pkce.ts create mode 100644 packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts create mode 100644 packages/card/src/hooks/useSendLoginOtpMutation.ts diff --git a/apps/mobile/src/__integration__/card-onboarding-address.test.tsx b/apps/mobile/src/__integration__/card-onboarding-address.test.tsx index 188d00fe3..d03ff3d1a 100644 --- a/apps/mobile/src/__integration__/card-onboarding-address.test.tsx +++ b/apps/mobile/src/__integration__/card-onboarding-address.test.tsx @@ -24,6 +24,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { Notifier } from 'react-native-notifier' import { OnboardingStep, useCardStore } from '@perawallet/wallet-core-card' +import { mockOauthChain } from '@perawallet/wallet-core-card/test-handlers' import { server } from '@test-utils/msw-server' import { renderWithNavigation } from '@test-utils/renderWithNavigation' @@ -145,6 +146,9 @@ describe('Flow: Card onboarding — residential address', () => { { status: 200 }, ), ), + // The registration-issued token is traded for the durable OAuth + // pair (initiate → authorize → token), same chain as sign-in. + ...mockOauthChain(), ) }) afterEach(() => server.resetHandlers()) diff --git a/apps/mobile/src/__integration__/card-sign-in.test.tsx b/apps/mobile/src/__integration__/card-sign-in.test.tsx index d0adbd924..89c55ba6f 100644 --- a/apps/mobile/src/__integration__/card-sign-in.test.tsx +++ b/apps/mobile/src/__integration__/card-sign-in.test.tsx @@ -25,6 +25,8 @@ import { http, HttpResponse } from 'msw' import { View } from 'react-native' import { Notifier } from 'react-native-notifier' +import { mockOauthChain } from '@perawallet/wallet-core-card/test-handlers' + import { server } from '@test-utils/msw-server' import { renderWithNavigation } from '@test-utils/renderWithNavigation' import { CardSignInScreen } from '@modules/card/screens/CardSignInScreen' @@ -53,6 +55,10 @@ const renderSignIn = () => ], }) +// The post-login OAuth code+PKCE chain (initiate → authorize with echoed +// CSRF state → token exchange) is stubbed by the package's mockOauthChain. +const stubOauthChain = () => server.use(...mockOauthChain()) + // Enter a valid email + password and wait for the Sign In button to enable. const fillCredentials = async () => { fireEvent.change(screen.getByTestId('card-sign-in-email-input'), { @@ -74,7 +80,7 @@ describe('Flow: Card sign in', () => { afterEach(() => server.resetHandlers()) afterAll(() => server.close()) - it('Given valid credentials and a ready account, when Sign In is pressed, then the session is created and the wallet home opens', async () => { + it('Given valid credentials and a ready account, when Sign In is pressed, then the OAuth exchange runs and the wallet home opens', async () => { const loginSpy = vi.fn(() => HttpResponse.json( { @@ -88,6 +94,7 @@ describe('Flow: Card sign in', () => { ), ) server.use(http.post('*/v1/auth/login', loginSpy)) + stubOauthChain() renderSignIn() await fillCredentials() @@ -100,6 +107,40 @@ describe('Flow: Card sign in', () => { expect(Notifier.showNotification).toHaveBeenCalled() }) + it('Given the OAuth exchange fails after a valid login, when Sign In is pressed, then a fallback session is created and the wallet home still opens', async () => { + // Credentials were accepted, so an OAuth-proxy outage must degrade to + // the refresh-less 6h session (pre-OAuth behavior), not fail login. + server.use( + http.post('*/v1/auth/login', () => + HttpResponse.json( + { + accessToken: 'access-token', + userId: 'user-1', + isOtpRequired: false, + phase: null, + isLinked: true, + }, + { status: 200 }, + ), + ), + // Initiate rejects — the chain never reaches the token exchange. + http.get('*/baanx/oauth/initiate', () => + HttpResponse.json( + { message: 'not configured' }, + { status: 500 }, + ), + ), + ) + + renderSignIn() + await fillCredentials() + fireEvent.click(screen.getByTestId('card-sign-in-submit')) + + await waitFor(() => + expect(screen.getByTestId('home-tab-stub')).toBeTruthy(), + ) + }) + it('Given a mid-registration login for an unverified user, when Sign In is pressed, then the KYC entry screen opens', async () => { // A mid-onboarding login returns a `phase` but no accessToken and a null // verificationState; the app reads the real KYC state from the pre-auth @@ -207,15 +248,20 @@ describe('Flow: Card sign in', () => { ) }) - it('Given the account requires a 2FA code, when Sign In is pressed, then the code input appears and a valid code completes sign-in', async () => { + it('Given the account requires a 2FA code, when Sign In is pressed, then the code is sent, the input appears, and a valid code completes sign-in', async () => { let calls = 0 + let otpRequestUserId: string | null = null server.use( http.post('*/v1/auth/login', () => { calls += 1 // First attempt: credentials accepted but a code is required. if (calls === 1) { return HttpResponse.json( - { accessToken: null, isOtpRequired: true }, + { + accessToken: null, + userId: 'user-2fa', + isOtpRequired: true, + }, { status: 200 }, ) } @@ -225,7 +271,15 @@ describe('Flow: Card sign in', () => { { status: 200 }, ) }), + // Baanx does not send the code on its own — the app must request + // it through the OTP endpoint, keyed on the login userId. + http.post('*/v1/auth/login/otp', async ({ request }) => { + const body = (await request.json()) as { userId?: string } + otpRequestUserId = body.userId ?? null + return HttpResponse.json({ success: true }, { status: 200 }) + }), ) + stubOauthChain() renderSignIn() await fillCredentials() @@ -233,6 +287,8 @@ describe('Flow: Card sign in', () => { // A full code auto-submits via the input's onComplete. const otp = await screen.findByTestId('card-sign-in-otp-input') + // The 2FA code was requested for the right user before entry. + await waitFor(() => expect(otpRequestUserId).toBe('user-2fa')) fireEvent.change(otp, { target: { value: '123456' } }) await waitFor(() => expect(calls).toBe(2)) @@ -259,6 +315,7 @@ describe('Flow: Card sign in', () => { ), ), ) + stubOauthChain() renderSignIn() await fillCredentials() diff --git a/apps/mobile/src/modules/card/screens/CardSignInScreen/__tests__/useCardSignInScreen.spec.ts b/apps/mobile/src/modules/card/screens/CardSignInScreen/__tests__/useCardSignInScreen.spec.ts index f6f69b83d..2272697e5 100644 --- a/apps/mobile/src/modules/card/screens/CardSignInScreen/__tests__/useCardSignInScreen.spec.ts +++ b/apps/mobile/src/modules/card/screens/CardSignInScreen/__tests__/useCardSignInScreen.spec.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockMutateAsync = vi.fn() const mockSetOnboardingStep = vi.fn() +const mockSendOtpMutateAsync = vi.fn() vi.mock('@perawallet/wallet-core-card', async () => { const actual = await vi.importActual< typeof import('@perawallet/wallet-core-card') @@ -37,6 +38,16 @@ vi.mock('@perawallet/wallet-core-card', async () => { setOnboardingStep: mockSetOnboardingStep, }), }), + useSendLoginOtpMutation: () => ({ + mutate: vi.fn(), + mutateAsync: mockSendOtpMutateAsync, + isPending: false, + isError: false, + isSuccess: false, + error: null, + data: null, + reset: vi.fn(), + }), } }) @@ -215,4 +226,16 @@ describe('useCardSignInScreen', () => { expect(mockNavigate).not.toHaveBeenCalled() }) }) + + it('does not request a resend before a login has required OTP', async () => { + // The OTP endpoint is keyed on the login's userId; without one (no + // isOtpRequired login yet) resend must be a no-op, not a bad request. + const { result } = renderHook(() => useCardSignInScreen()) + + await act(async () => { + result.current.handleResendOtp() + }) + + expect(mockSendOtpMutateAsync).not.toHaveBeenCalled() + }) }) diff --git a/apps/mobile/src/modules/card/screens/CardSignInScreen/useCardSignInScreen.ts b/apps/mobile/src/modules/card/screens/CardSignInScreen/useCardSignInScreen.ts index 7ad75b5ec..93c38b8a7 100644 --- a/apps/mobile/src/modules/card/screens/CardSignInScreen/useCardSignInScreen.ts +++ b/apps/mobile/src/modules/card/screens/CardSignInScreen/useCardSignInScreen.ts @@ -19,6 +19,7 @@ import { signInSchema, useCardLoginMutation, useCardStore, + useSendLoginOtpMutation, type SignInFormValues, } from '@perawallet/wallet-core-card' import { useAppNavigation } from '@hooks/useAppNavigation' @@ -60,6 +61,7 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { const { errorToast, successToast, infoToast } = useToast() const navigation = useAppNavigation() const login = useCardLoginMutation() + const sendOtp = useSendLoginOtpMutation() const { control, @@ -74,6 +76,9 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { }) const [isOtpRequired, setIsOtpRequired] = useState(false) + // `userId` from the login attempt that required 2FA — /v1/auth/login/otp + // is keyed on it for both the initial send and resends. + const [otpUserId, setOtpUserId] = useState(null) const [otpCode, setOtpCode] = useState('') const [hasOtpError, setHasOtpError] = useState(false) const { secondsRemaining, isActive, restart } = useCountdown( @@ -89,9 +94,31 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { [hasOtpError], ) - // The single login call handles both passes: the first (no `otp`) may come - // back `isOtpRequired`, which reveals the code input; the second carries the - // code. The session is persisted inside the mutation's `onSuccess`. + // Asks Baanx to send (or re-send) the 2FA code. Baanx does not send it on + // its own when login returns `isOtpRequired` — this call is what triggers + // the SMS. The resend cooldown arms only when the send succeeds; a failure + // surfaces as a toast and leaves the button usable for an immediate retry. + // Depends on the stable `mutateAsync` (not the per-render mutation object) + // so the callback chain below isn't recreated every keystroke. + const sendOtpAsync = sendOtp.mutateAsync + const requestOtpCode = useCallback( + (userId: string) => { + sendOtpAsync({ userId }) + .then(() => restart()) + .catch(() => { + errorToast( + t('peraCard.sign_in.error_title'), + t('peraCard.sign_in.error_body'), + ) + }) + }, + [sendOtpAsync, restart, errorToast, t], + ) + + // The login call handles both passes: the first (no `otp`) may come back + // `isOtpRequired`, which triggers the OTP send and reveals the code input; + // the second carries the code and completes the OAuth exchange. The session + // is persisted inside the mutation's `onSuccess`. const performLogin = useCallback( async (email: string, password: string, otp?: string) => { try { @@ -101,11 +128,22 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { otpCode: otp, }) - // Credentials accepted but a 2FA code is required — reveal the - // code input and arm the resend cooldown. + // Credentials accepted but a 2FA code is required — ask Baanx + // to send the code and reveal the input (the send arms the + // resend cooldown when it succeeds). Without a userId the code + // can never be requested, so surface an error instead of an + // input no code will reach. if (result.isOtpRequired && !result.accessToken) { + if (!result.userId) { + errorToast( + t('peraCard.sign_in.error_title'), + t('peraCard.sign_in.error_body'), + ) + return + } setIsOtpRequired(true) - restart() + setOtpUserId(result.userId) + requestOtpCode(result.userId) return } @@ -185,7 +223,15 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { ) } }, - [login, navigation, setError, errorToast, successToast, restart, t], + [ + login, + navigation, + setError, + errorToast, + successToast, + requestOtpCode, + t, + ], ) const handleSignIn = useCallback( @@ -205,15 +251,17 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => { ) const handleResendOtp = useCallback(() => { - // Re-issuing the code: Baanx re-sends when login runs again without an - // otpCode. TODO(card): confirm this is the intended resend behavior. - if (login.isPending) return + // Re-issue the code through the dedicated OTP endpoint — no need to + // re-run the whole login. The cooldown re-arms inside requestOtpCode + // only if the send succeeds. Blocked while a send OR the OTP-carrying + // login is in flight, so a fresh code can't race the verification of + // the one being checked. + if (!otpUserId || sendOtp.isPending || login.isPending) return // A fresh code is on its way — clear any "wrong code" error from the // prior attempt so the re-armed input starts clean. setHasOtpError(false) - const { email, password } = getValues() - void performLogin(email, password) - }, [login.isPending, getValues, performLogin]) + requestOtpCode(otpUserId) + }, [otpUserId, sendOtp.isPending, login.isPending, requestOtpCode]) const handleForgotPassword = useCallback(() => { // TODO(card): wire to the real forgot-password flow once designed. diff --git a/packages/app-integrity/src/services/__tests__/getValidIntegrityToken.test.ts b/packages/app-integrity/src/services/__tests__/getValidIntegrityToken.test.ts new file mode 100644 index 000000000..ce42e55e3 --- /dev/null +++ b/packages/app-integrity/src/services/__tests__/getValidIntegrityToken.test.ts @@ -0,0 +1,54 @@ +/* + Copyright 2022-2026 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { useAppIntegrityStore } from '../../store' +import { getValidIntegrityToken } from '../getValidIntegrityToken' + +const registration = (expiresAt: string) => ({ + integrityToken: 'attestation-jwt', + expiresAt, + keyId: 'k1', + deviceId: 'd1', +}) + +describe('getValidIntegrityToken', () => { + beforeEach(() => { + useAppIntegrityStore.getState().resetState() + }) + + it('returns the token while it is unexpired', () => { + const future = new Date(Date.now() + 60_000).toISOString() + useAppIntegrityStore.getState().setRegistration(registration(future)) + + expect(getValidIntegrityToken()).toBe('attestation-jwt') + }) + + it('returns null when the token is expired', () => { + const past = new Date(Date.now() - 60_000).toISOString() + useAppIntegrityStore.getState().setRegistration(registration(past)) + + expect(getValidIntegrityToken()).toBeNull() + }) + + it('returns null when no registration is stored', () => { + expect(getValidIntegrityToken()).toBeNull() + }) + + it('returns null for an unparseable expiry', () => { + useAppIntegrityStore + .getState() + .setRegistration(registration('not-a-date')) + + expect(getValidIntegrityToken()).toBeNull() + }) +}) diff --git a/packages/app-integrity/src/services/getValidIntegrityToken.ts b/packages/app-integrity/src/services/getValidIntegrityToken.ts new file mode 100644 index 000000000..b18f9539d --- /dev/null +++ b/packages/app-integrity/src/services/getValidIntegrityToken.ts @@ -0,0 +1,31 @@ +/* + Copyright 2022-2026 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { Nullable } from '@perawallet/wallet-core-shared' +import { useAppIntegrityStore } from '../store' + +/** + * The current non-expired device attestation token, or null when absent or + * expired. Read synchronously from the store snapshot so callers decide at + * call time, not render time — pass it as the `x-app-integrity-token` header + * on integrity-guarded backend routes. + */ +export const getValidIntegrityToken = (): Nullable => { + const { integrityToken, expiresAt } = useAppIntegrityStore.getState() + if (!integrityToken || !expiresAt) { + return null + } + const expiry = Date.parse(expiresAt) + return Number.isFinite(expiry) && expiry > Date.now() + ? integrityToken + : null +} diff --git a/packages/app-integrity/src/services/index.ts b/packages/app-integrity/src/services/index.ts index a2ae3126e..e7d1bc354 100644 --- a/packages/app-integrity/src/services/index.ts +++ b/packages/app-integrity/src/services/index.ts @@ -13,3 +13,4 @@ // `registerAppIntegrity` stays package-internal; business logic is exposed via // hooks (useAppIntegrityRegistration, useAppIntegrityBootstrap). export { type RegisterAppIntegrityResult } from './registerAppIntegrity' +export { getValidIntegrityToken } from './getValidIntegrityToken' diff --git a/packages/card/package.json b/packages/card/package.json index d46135109..1de5ecc9a 100644 --- a/packages/card/package.json +++ b/packages/card/package.json @@ -18,6 +18,8 @@ }, "dependencies": { "@babel/runtime": "catalog:", + "@noble/hashes": "catalog:", + "@perawallet/wallet-core-app-integrity": "workspace:*", "@perawallet/wallet-core-blockchain": "workspace:*", "@perawallet/wallet-core-config": "workspace:*", "@perawallet/wallet-core-kms": "workspace:*", diff --git a/packages/card/src/api/auth/__tests__/endpoints.test.ts b/packages/card/src/api/auth/__tests__/endpoints.test.ts index 5dc21336d..b413380bc 100644 --- a/packages/card/src/api/auth/__tests__/endpoints.test.ts +++ b/packages/card/src/api/auth/__tests__/endpoints.test.ts @@ -15,7 +15,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const { request } = vi.hoisted(() => ({ request: vi.fn() })) vi.mock('../../transport', () => ({ getCardTransport: () => ({ request }) })) -import { loginRequest, refreshTokenRequest } from '../endpoints' +import { + loginRequest, + oauthAuthorizeRequest, + oauthInitiateRequest, + oauthTokenRequest, + refreshTokenRequest, + sendLoginOtpRequest, +} from '../endpoints' describe('auth endpoints', () => { beforeEach(() => vi.clearAllMocks()) @@ -50,16 +57,119 @@ describe('auth endpoints', () => { expect(call.route).toBeUndefined() }) - it('refreshes via the proxy route (server-only x-secret-key)', async () => { + it('sends the login OTP via the direct route', async () => { + request.mockResolvedValue({ data: { success: true } }) + + await sendLoginOtpRequest({ userId: 'user-1', network: 'mainnet' }) + + const call = request.mock.calls[0][0] + expect(call).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/v1/auth/login/otp', + data: { userId: 'user-1' }, + }), + ) + expect(call.route).toBeUndefined() + expect(call.authenticated).toBeUndefined() + }) + + it('rejects when the OTP send reports success: false', async () => { + request.mockResolvedValue({ data: { success: false } }) + + await expect( + sendLoginOtpRequest({ userId: 'user-1', network: 'mainnet' }), + ).rejects.toThrow('Baanx declined to send the login OTP') + }) + + it('initiates OAuth via the proxy route (pinned client_id/redirect_uri)', async () => { + request.mockResolvedValue({ + data: { token: 'jwt-session', url: 'https://hosted' }, + }) + + const initiation = await oauthInitiateRequest({ + state: 'csrf-state-123', + codeChallenge: 'challenge-abc', + network: 'mainnet', + }) + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + route: 'proxy', + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + params: { + state: 'csrf-state-123', + code_challenge: 'challenge-abc', + }, + }), + ) + expect(initiation.sessionToken).toBe('jwt-session') + }) + + it('authorizes directly with the ephemeral login Bearer, not the keystore', async () => { + request.mockResolvedValue({ + data: { code: 'auth-code', state: 'csrf-state-123' }, + }) + + const authorization = await oauthAuthorizeRequest({ + sessionToken: 'jwt-session', + accessToken: 'login-access-token', + network: 'mainnet', + }) + + const call = request.mock.calls[0][0] + expect(call).toEqual( + expect.objectContaining({ + method: 'POST', + path: '/v1/auth/oauth/authorize', + data: { token: 'jwt-session' }, + headers: { Authorization: 'Bearer login-access-token' }, + }), + ) + // The login token is passed explicitly; the keystore Bearer (set via + // `authenticated`) must stay off — no durable session exists yet. + expect(call.route).toBeUndefined() + expect(call.authenticated).toBeUndefined() + expect(authorization).toEqual({ + code: 'auth-code', + state: 'csrf-state-123', + }) + }) + + it('exchanges the authorization code via the proxy route', async () => { request.mockResolvedValue({ data: { access_token: 'x', refresh_token: 'y', expires_in: 60 }, }) - await refreshTokenRequest({ refreshToken: 'r', network: 'mainnet' }) + const tokens = await oauthTokenRequest({ + code: 'auth-code', + codeVerifier: 'verifier-123', + network: 'mainnet', + }) expect(request).toHaveBeenCalledWith( expect.objectContaining({ route: 'proxy', + method: 'POST', + path: '/api/v3/baanx/oauth/token', + data: { code: 'auth-code', code_verifier: 'verifier-123' }, + }), + ) + expect(tokens).toEqual({ accessToken: 'x', refreshToken: 'y' }) + }) + + it('refreshes via the direct route with the x-client-key alone', async () => { + request.mockResolvedValue({ + data: { access_token: 'x', refresh_token: 'y', expires_in: 60 }, + }) + + await refreshTokenRequest({ refreshToken: 'r', network: 'mainnet' }) + + const call = request.mock.calls[0][0] + expect(call).toEqual( + expect.objectContaining({ + method: 'POST', path: '/v1/auth/oauth/token', data: expect.objectContaining({ grant_type: 'refresh_token', @@ -67,5 +177,10 @@ describe('auth endpoints', () => { }), }), ) + // Direct (the proxy only accepts the authorization-code grant), and + // never `authenticated` — a 401 here must not re-enter the refresh + // handler. + expect(call.route).toBeUndefined() + expect(call.authenticated).toBeUndefined() }) }) diff --git a/packages/card/src/api/auth/__tests__/oauth-login.test.ts b/packages/card/src/api/auth/__tests__/oauth-login.test.ts new file mode 100644 index 000000000..1fb5c8387 --- /dev/null +++ b/packages/card/src/api/auth/__tests__/oauth-login.test.ts @@ -0,0 +1,149 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { oauthInitiateRequest, oauthAuthorizeRequest, oauthTokenRequest } = + vi.hoisted(() => ({ + oauthInitiateRequest: vi.fn(), + oauthAuthorizeRequest: vi.fn(), + oauthTokenRequest: vi.fn(), + })) +vi.mock('../endpoints', () => ({ + oauthInitiateRequest, + oauthAuthorizeRequest, + oauthTokenRequest, +})) + +import { + acquireCardSessionTokens, + exchangeLoginForOauthTokens, + OauthStateMismatchError, +} from '../oauth-login' +import { createCodeChallenge } from '../pkce' + +describe('exchangeLoginForOauthTokens', () => { + beforeEach(() => vi.clearAllMocks()) + + it('chains initiate → authorize → token with consistent PKCE material', async () => { + oauthInitiateRequest.mockResolvedValue({ sessionToken: 'jwt-session' }) + // Echo back whatever state the flow generated, as Baanx would. + oauthAuthorizeRequest.mockImplementation(async () => ({ + code: 'auth-code', + state: oauthInitiateRequest.mock.calls[0][0].state, + })) + oauthTokenRequest.mockResolvedValue({ + accessToken: 'x', + refreshToken: 'y', + }) + + const tokens = await exchangeLoginForOauthTokens({ + accessToken: 'login-token', + network: 'mainnet', + }) + + const initiateArgs = oauthInitiateRequest.mock.calls[0][0] + expect(initiateArgs.state).toMatch(/^[A-Za-z0-9_-]{8,}$/) + + expect(oauthAuthorizeRequest).toHaveBeenCalledWith( + expect.objectContaining({ + sessionToken: 'jwt-session', + accessToken: 'login-token', + network: 'mainnet', + }), + ) + + // The verifier sent to the token exchange must hash to the challenge + // sent to initiate — otherwise Baanx's S256 check rejects the code. + const tokenArgs = oauthTokenRequest.mock.calls[0][0] + expect(tokenArgs.code).toBe('auth-code') + expect(createCodeChallenge(tokenArgs.codeVerifier)).toBe( + initiateArgs.codeChallenge, + ) + + expect(tokens).toEqual({ accessToken: 'x', refreshToken: 'y' }) + }) + + it('rejects on a state mismatch without exchanging the code', async () => { + oauthInitiateRequest.mockResolvedValue({ sessionToken: 'jwt-session' }) + oauthAuthorizeRequest.mockResolvedValue({ + code: 'auth-code', + state: 'tampered-state', + }) + + await expect( + exchangeLoginForOauthTokens({ + accessToken: 'login-token', + network: 'mainnet', + }), + ).rejects.toBeInstanceOf(OauthStateMismatchError) + expect(oauthTokenRequest).not.toHaveBeenCalled() + }) + + it('acquireCardSessionTokens returns the exchanged pair on success', async () => { + oauthInitiateRequest.mockResolvedValue({ sessionToken: 'jwt-session' }) + oauthAuthorizeRequest.mockImplementation(async () => ({ + code: 'auth-code', + state: oauthInitiateRequest.mock.calls[0][0].state, + })) + oauthTokenRequest.mockResolvedValue({ + accessToken: 'x', + refreshToken: 'y', + }) + + await expect( + acquireCardSessionTokens({ + accessToken: 'login-token', + network: 'mainnet', + }), + ).resolves.toEqual({ accessToken: 'x', refreshToken: 'y' }) + }) + + it('acquireCardSessionTokens falls back to a refresh-less pair when the exchange fails', async () => { + // Credentials were already accepted — an OAuth outage must degrade + // the session, never fail the login/registration that produced it. + oauthInitiateRequest.mockRejectedValue(new Error('proxy down')) + + await expect( + acquireCardSessionTokens({ + accessToken: 'login-token', + network: 'mainnet', + }), + ).resolves.toEqual({ accessToken: 'login-token', refreshToken: '' }) + expect(oauthTokenRequest).not.toHaveBeenCalled() + }) + + it('generates fresh PKCE material per attempt', async () => { + oauthInitiateRequest.mockResolvedValue({ sessionToken: 'jwt-session' }) + oauthAuthorizeRequest.mockImplementation(async () => ({ + code: 'auth-code', + state: oauthInitiateRequest.mock.calls.at(-1)?.[0].state, + })) + oauthTokenRequest.mockResolvedValue({ + accessToken: 'x', + refreshToken: 'y', + }) + + await exchangeLoginForOauthTokens({ + accessToken: 'login-token', + network: 'mainnet', + }) + await exchangeLoginForOauthTokens({ + accessToken: 'login-token', + network: 'mainnet', + }) + + const [first, second] = oauthInitiateRequest.mock.calls + expect(first[0].codeChallenge).not.toBe(second[0].codeChallenge) + expect(first[0].state).not.toBe(second[0].state) + }) +}) diff --git a/packages/card/src/api/auth/__tests__/pkce.test.ts b/packages/card/src/api/auth/__tests__/pkce.test.ts new file mode 100644 index 000000000..1dced895f --- /dev/null +++ b/packages/card/src/api/auth/__tests__/pkce.test.ts @@ -0,0 +1,46 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect } from 'vitest' +import { createCodeChallenge, createOauthState, createPkcePair } from '../pkce' + +describe('pkce', () => { + it('derives the RFC 7636 appendix-B challenge from its verifier', () => { + // Known-answer test from the PKCE spec: any deviation in hashing or + // base64url encoding (padding, +/ chars) breaks Baanx's S256 check. + expect( + createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), + ).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + }) + + it('generates a spec-compliant verifier/challenge pair', () => { + const { codeVerifier, codeChallenge } = createPkcePair() + + // 32 random bytes → 43 base64url chars, the RFC 7636 minimum length, + // drawn from the unreserved subset Baanx validates ([A-Za-z0-9_-]). + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(codeChallenge).toBe(createCodeChallenge(codeVerifier)) + }) + + it('generates unique verifiers per pair', () => { + expect(createPkcePair().codeVerifier).not.toBe( + createPkcePair().codeVerifier, + ) + }) + + it('generates a unique CSRF state of at least 8 chars', () => { + const state = createOauthState() + expect(state).toMatch(/^[A-Za-z0-9_-]{8,}$/) + expect(createOauthState()).not.toBe(state) + }) +}) diff --git a/packages/card/src/api/auth/__tests__/transformers.test.ts b/packages/card/src/api/auth/__tests__/transformers.test.ts index d65567fe7..2d48cf619 100644 --- a/packages/card/src/api/auth/__tests__/transformers.test.ts +++ b/packages/card/src/api/auth/__tests__/transformers.test.ts @@ -11,7 +11,12 @@ */ import { describe, it, expect } from 'vitest' -import { transformLoginResponse, transformTokenResponse } from '../transformers' +import { + transformLoginResponse, + transformOauthAuthorizeResponse, + transformOauthInitiateResponse, + transformTokenResponse, +} from '../transformers' describe('auth transformers', () => { it('maps a login response (access token only, no refresh)', () => { @@ -63,4 +68,22 @@ describe('auth transformers', () => { expect(tokens.accessToken).toBe('x') expect(tokens.refreshToken).toBe('y') }) + + it('maps the initiate response token to the OAuth session token', () => { + const initiation = transformOauthInitiateResponse({ + token: 'jwt-session', + }) + + expect(initiation.sessionToken).toBe('jwt-session') + }) + + it('maps the authorize response to code + echoed state', () => { + const authorization = transformOauthAuthorizeResponse({ + code: 'auth-code', + state: 'csrf-state', + }) + + expect(authorization.code).toBe('auth-code') + expect(authorization.state).toBe('csrf-state') + }) }) diff --git a/packages/card/src/api/auth/endpoints.ts b/packages/card/src/api/auth/endpoints.ts index d33367426..1c3f4b204 100644 --- a/packages/card/src/api/auth/endpoints.ts +++ b/packages/card/src/api/auth/endpoints.ts @@ -12,9 +12,25 @@ import type { Network } from '@perawallet/wallet-core-shared' import { getCardTransport } from '../transport' -import type { CardSessionTokens, LoginResult } from '../../models' -import { loginResponseSchema, tokenResponseSchema } from './schema' -import { transformLoginResponse, transformTokenResponse } from './transformers' +import type { + CardSessionTokens, + LoginResult, + OauthAuthorization, + OauthInitiation, +} from '../../models' +import { + loginResponseSchema, + oauthAuthorizeResponseSchema, + oauthInitiateResponseSchema, + sendLoginOtpResponseSchema, + tokenResponseSchema, +} from './schema' +import { + transformLoginResponse, + transformOauthAuthorizeResponse, + transformOauthInitiateResponse, + transformTokenResponse, +} from './transformers' export type LoginRequestParams = { email: string @@ -40,6 +56,139 @@ export const loginRequest = async ( return transformLoginResponse(loginResponseSchema.parse(response.data)) } +export type SendLoginOtpRequestParams = { + userId: string + network: Network + signal?: AbortSignal +} + +/** + * Asks Baanx to send the login 2FA code (SMS) for a user whose login came back + * `isOtpRequired`. Baanx does NOT send the code on its own — this call is + * required before the user can retry login with `otpCode`. Rejects on a 200 + * that reports `success: false` (e.g. send suppressed), so callers can treat + * "resolved" as "a code is on its way". + */ +export const sendLoginOtpRequest = async ( + params: SendLoginOtpRequestParams, +): Promise => { + const { userId, network, signal } = params + + const response = await getCardTransport().request({ + network, + method: 'POST', + path: '/v1/auth/login/otp', + data: { userId }, + signal, + }) + + const { success } = sendLoginOtpResponseSchema.parse(response.data) + if (success === false) { + throw new Error('Baanx declined to send the login OTP') + } +} + +export type OauthInitiateRequestParams = { + /** CSRF token (≥ 8 chars); the authorize step must echo it back. */ + state: string + /** BASE64URL(SHA256(code_verifier)), method S256. */ + codeChallenge: string + network: Network + signal?: AbortSignal +} + +/** + * OAuth step 1, via Pera's backend (`proxy`): the backend pins `client_id` / + * `redirect_uri` and injects the server-only x-secret-key (initiate is the + * only Baanx auth endpoint that takes it), then forwards to Baanx's + * /v1/auth/oauth/authorize/initiate in API mode. Returns the 10-minute + * session JWT consumed by {@link oauthAuthorizeRequest}. + */ +export const oauthInitiateRequest = async ( + params: OauthInitiateRequestParams, +): Promise => { + const { state, codeChallenge, network, signal } = params + + const response = await getCardTransport().request({ + route: 'proxy', + network, + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + params: { state, code_challenge: codeChallenge }, + signal, + }) + + return transformOauthInitiateResponse( + oauthInitiateResponseSchema.parse(response.data), + ) +} + +export type OauthAuthorizeRequestParams = { + /** Session JWT from {@link oauthInitiateRequest}. */ + sessionToken: string + /** Ephemeral access token from {@link loginRequest} (OAuth step 2). */ + accessToken: string + network: Network + signal?: AbortSignal +} + +/** + * OAuth step 3, direct to Baanx: trades the initiate JWT + the ephemeral login + * Bearer for a single-use authorization code. The Bearer is passed explicitly + * (NOT via `authenticated`) — the keystore holds no session yet, and a prior + * session's token must not leak in. + */ +export const oauthAuthorizeRequest = async ( + params: OauthAuthorizeRequestParams, +): Promise => { + const { sessionToken, accessToken, network, signal } = params + + const response = await getCardTransport().request({ + network, + method: 'POST', + path: '/v1/auth/oauth/authorize', + data: { token: sessionToken }, + headers: { Authorization: `Bearer ${accessToken}` }, + signal, + }) + + return transformOauthAuthorizeResponse( + oauthAuthorizeResponseSchema.parse(response.data), + ) +} + +export type OauthTokenRequestParams = { + /** Single-use authorization code from {@link oauthAuthorizeRequest}. */ + code: string + /** The original PKCE verifier whose challenge was sent to initiate. */ + codeVerifier: string + network: Network + signal?: AbortSignal +} + +/** + * OAuth step 4, via Pera's backend (`proxy`): the backend pins `redirect_uri` + * (its token schema accepts only `code` + `code_verifier`) and forwards the + * authorization-code grant to Baanx. Returns the durable 6h access / 7-day + * refresh pair. + */ +export const oauthTokenRequest = async ( + params: OauthTokenRequestParams, +): Promise => { + const { code, codeVerifier, network, signal } = params + + const response = await getCardTransport().request({ + route: 'proxy', + network, + method: 'POST', + path: '/api/v3/baanx/oauth/token', + data: { code, code_verifier: codeVerifier }, + signal, + }) + + return transformTokenResponse(tokenResponseSchema.parse(response.data)) +} + export type RefreshTokenRequestParams = { refreshToken: string network: Network @@ -47,8 +196,12 @@ export type RefreshTokenRequestParams = { } /** - * Exchanges the refresh token for a fresh access token. Routed through Pera's - * backend (`proxy`) since the OAuth token endpoint requires the x-secret-key. + * Exchanges the refresh token for a fresh token pair, direct to Baanx: the + * refresh grant authenticates with the x-client-key alone (Pera's backend + * proxy accepts only the authorization-code grant, and Baanx rejects extra + * client auth on this endpoint). Never marked `authenticated` — a 401 from an + * expired refresh token must surface instead of re-entering the transport's + * refresh handler. */ export const refreshTokenRequest = async ( params: RefreshTokenRequestParams, @@ -56,7 +209,6 @@ export const refreshTokenRequest = async ( const { refreshToken, network, signal } = params const response = await getCardTransport().request({ - route: 'proxy', network, method: 'POST', path: '/v1/auth/oauth/token', diff --git a/packages/card/src/api/auth/index.ts b/packages/card/src/api/auth/index.ts index 1432afbe8..fd695fee8 100644 --- a/packages/card/src/api/auth/index.ts +++ b/packages/card/src/api/auth/index.ts @@ -12,7 +12,27 @@ export { loginRequest, + oauthAuthorizeRequest, + oauthInitiateRequest, + oauthTokenRequest, refreshTokenRequest, + sendLoginOtpRequest, type LoginRequestParams, + type OauthAuthorizeRequestParams, + type OauthInitiateRequestParams, + type OauthTokenRequestParams, type RefreshTokenRequestParams, + type SendLoginOtpRequestParams, } from './endpoints' +export { + acquireCardSessionTokens, + exchangeLoginForOauthTokens, + OauthStateMismatchError, + type ExchangeLoginForOauthTokensParams, +} from './oauth-login' +export { + createCodeChallenge, + createOauthState, + createPkcePair, + type PkcePair, +} from './pkce' diff --git a/packages/card/src/api/auth/msw-handlers.ts b/packages/card/src/api/auth/msw-handlers.ts index 6834d7c23..52641698f 100644 --- a/packages/card/src/api/auth/msw-handlers.ts +++ b/packages/card/src/api/auth/msw-handlers.ts @@ -14,8 +14,14 @@ import { http, HttpResponse, type HttpHandler } from 'msw' import { validateMockResponse } from '@perawallet/wallet-core-shared/test-utils' import { loginResponseSchema, + oauthAuthorizeResponseSchema, + oauthInitiateResponseSchema, + sendLoginOtpResponseSchema, tokenResponseSchema, type LoginApiResponse, + type OauthAuthorizeApiResponse, + type OauthInitiateApiResponse, + type SendLoginOtpApiResponse, type TokenApiResponse, } from './schema' @@ -34,6 +40,82 @@ export const mockLogin = ({ ) } +export type MockSendLoginOtpParams = { + response?: SendLoginOtpApiResponse + status?: number +} + +export const mockSendLoginOtp = ({ + response = { success: true }, + status = 200, +}: MockSendLoginOtpParams = {}): HttpHandler => { + validateMockResponse( + sendLoginOtpResponseSchema, + response, + 'mockSendLoginOtp', + ) + return http.post('*/v1/auth/login/otp', () => + HttpResponse.json(response, { status }), + ) +} + +export type MockOauthInitiateParams = { + response: OauthInitiateApiResponse + status?: number +} + +// Matches the Pera-backend proxy path (/api/v3/baanx/oauth/initiate). +export const mockOauthInitiate = ({ + response, + status = 200, +}: MockOauthInitiateParams): HttpHandler => { + validateMockResponse( + oauthInitiateResponseSchema, + response, + 'mockOauthInitiate', + ) + return http.get('*/baanx/oauth/initiate', () => + HttpResponse.json(response, { status }), + ) +} + +export type MockOauthAuthorizeParams = { + response: OauthAuthorizeApiResponse + status?: number +} + +export const mockOauthAuthorize = ({ + response, + status = 200, +}: MockOauthAuthorizeParams): HttpHandler => { + validateMockResponse( + oauthAuthorizeResponseSchema, + response, + 'mockOauthAuthorize', + ) + return http.post('*/v1/auth/oauth/authorize', () => + HttpResponse.json(response, { status }), + ) +} + +export type MockOauthTokenParams = { + response: TokenApiResponse + status?: number +} + +// Matches the Pera-backend proxy path (/api/v3/baanx/oauth/token) — the +// authorization-code exchange. The refresh grant hits Baanx directly at +// /v1/auth/oauth/token; use mockRefreshToken for that. +export const mockOauthToken = ({ + response, + status = 200, +}: MockOauthTokenParams): HttpHandler => { + validateMockResponse(tokenResponseSchema, response, 'mockOauthToken') + return http.post('*/baanx/oauth/token', () => + HttpResponse.json(response, { status }), + ) +} + export type MockRefreshTokenParams = { response: TokenApiResponse status?: number @@ -48,3 +130,47 @@ export const mockRefreshToken = ({ HttpResponse.json(response, { status }), ) } + +export type MockOauthChainParams = { + /** Token payload returned by the final exchange step. */ + tokenResponse?: TokenApiResponse + /** Authorization code the authorize step hands back. */ + code?: string +} + +/** + * Stubs the whole happy-path OAuth chain — initiate (Pera proxy) → authorize + * (direct) → code exchange (Pera proxy) — with the CSRF `state` captured from + * initiate and echoed by authorize, as Baanx does. Use the individual + * factories above for error-path stubbing; this is the one-call happy path. + */ +export const mockOauthChain = ({ + tokenResponse = { + access_token: 'oauth-access-token', + expires_in: 21_600, + refresh_token: 'oauth-refresh-token', + refresh_token_expires_in: 604_800, + }, + code = 'auth-code-1', +}: MockOauthChainParams = {}): HttpHandler[] => { + validateMockResponse(tokenResponseSchema, tokenResponse, 'mockOauthChain') + let capturedState: string | null = null + return [ + http.get('*/baanx/oauth/initiate', ({ request }) => { + capturedState = new URL(request.url).searchParams.get('state') + return HttpResponse.json( + { token: 'oauth-session-jwt' }, + { status: 200 }, + ) + }), + http.post('*/v1/auth/oauth/authorize', () => + HttpResponse.json( + { code, state: capturedState }, + { status: 200 }, + ), + ), + http.post('*/baanx/oauth/token', () => + HttpResponse.json(tokenResponse, { status: 200 }), + ), + ] +} diff --git a/packages/card/src/api/auth/oauth-login.ts b/packages/card/src/api/auth/oauth-login.ts new file mode 100644 index 000000000..b84f93a42 --- /dev/null +++ b/packages/card/src/api/auth/oauth-login.ts @@ -0,0 +1,101 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { logger, type Network } from '@perawallet/wallet-core-shared' +import type { CardSessionTokens } from '../../models' +import { + oauthAuthorizeRequest, + oauthInitiateRequest, + oauthTokenRequest, +} from './endpoints' +import { createOauthState, createPkcePair } from './pkce' + +/** The authorize step echoed a different `state` than we sent — possible CSRF. */ +export class OauthStateMismatchError extends Error { + constructor() { + super('Baanx OAuth state mismatch') + this.name = 'OauthStateMismatchError' + } +} + +export type ExchangeLoginForOauthTokensParams = { + /** Ephemeral access token from a successful `loginRequest` (OAuth step 2). */ + accessToken: string + network: Network + signal?: AbortSignal +} + +/** + * Completes the OAuth 2.0 authorization-code + PKCE flow (API mode) after a + * successful credential login: initiate (proxied, pinned client) → authorize + * (direct, login Bearer) → code-for-tokens exchange (proxied). Fresh PKCE and + * `state` material is generated per attempt; the initiate session JWT lives + * 10 minutes, far beyond this immediate chain. Returns the durable 6h access / + * 7-day refresh pair to persist. + */ +export const exchangeLoginForOauthTokens = async ( + params: ExchangeLoginForOauthTokensParams, +): Promise => { + const { accessToken, network, signal } = params + + const { codeVerifier, codeChallenge } = createPkcePair() + const state = createOauthState() + + const { sessionToken } = await oauthInitiateRequest({ + state, + codeChallenge, + network, + signal, + }) + + const authorization = await oauthAuthorizeRequest({ + sessionToken, + accessToken, + network, + signal, + }) + + if (authorization.state !== state) { + throw new OauthStateMismatchError() + } + + return oauthTokenRequest({ + code: authorization.code, + codeVerifier, + network, + signal, + }) +} + +/** + * Runs the OAuth exchange and, if any step fails, falls back to a session + * built from the ephemeral access token alone (no refresh token — the same + * shape direct login produced before OAuth). The credentials were already + * accepted at this point, so an OAuth-proxy outage must degrade the session + * (re-login at the 6h expiry) rather than fail the whole login/registration. + * The failure — including a state mismatch, a possible CSRF signal — is + * logged for diagnosis; the fallback token itself never transited a + * redirect, so using it is no worse than the pre-OAuth flow. + */ +export const acquireCardSessionTokens = async ( + params: ExchangeLoginForOauthTokensParams, +): Promise => { + try { + return await exchangeLoginForOauthTokens(params) + } catch (error) { + logger.warn( + 'Baanx OAuth exchange failed; falling back to an access-token-only session', + { error }, + ) + return { accessToken: params.accessToken, refreshToken: '' } + } +} diff --git a/packages/card/src/api/auth/pkce.ts b/packages/card/src/api/auth/pkce.ts new file mode 100644 index 000000000..bdadc8945 --- /dev/null +++ b/packages/card/src/api/auth/pkce.ts @@ -0,0 +1,53 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { sha256 } from '@noble/hashes/sha2.js' +import { utf8ToBytes } from '@noble/hashes/utils.js' +import { encodeToBase64, toUrlSafeBase64 } from '@perawallet/wallet-core-shared' + +/** + * PKCE material for one OAuth attempt (RFC 7636). The verifier stays on this + * device until the token exchange; only the challenge travels with the + * authorization request. + */ +export type PkcePair = { + /** 43 base64url chars — within the spec's 43–128 unreserved-charset range. */ + codeVerifier: string + /** BASE64URL(SHA256(ASCII(codeVerifier))), method S256. */ + codeChallenge: string +} + +// Unpadded base64url — Baanx validates against [A-Za-z0-9_-] with no '='. +const bytesToBase64Url = (bytes: Uint8Array): string => + toUrlSafeBase64(encodeToBase64(bytes)) + +const randomBase64Url = (byteLength: number): string => { + const bytes = new Uint8Array(byteLength) + crypto.getRandomValues(bytes) + return bytesToBase64Url(bytes) +} + +/** S256 challenge for a given verifier — split out for known-answer tests. */ +export const createCodeChallenge = (codeVerifier: string): string => + bytesToBase64Url(sha256(utf8ToBytes(codeVerifier))) + +/** Fresh verifier + S256 challenge from 32 CSPRNG bytes. One per login attempt. */ +export const createPkcePair = (): PkcePair => { + const codeVerifier = randomBase64Url(32) + return { codeVerifier, codeChallenge: createCodeChallenge(codeVerifier) } +} + +/** + * CSRF `state` for the authorize round-trip (Baanx requires ≥ 8 chars). The + * value echoed back by /v1/auth/oauth/authorize must equal this original. + */ +export const createOauthState = (): string => randomBase64Url(16) diff --git a/packages/card/src/api/auth/schema.ts b/packages/card/src/api/auth/schema.ts index f2e8724ed..706409901 100644 --- a/packages/card/src/api/auth/schema.ts +++ b/packages/card/src/api/auth/schema.ts @@ -25,8 +25,38 @@ export const loginResponseSchema = z.object({ }) export type LoginApiResponse = z.infer -// POST /v1/auth/oauth/token (grant_type=refresh_token). Proxied via Pera's -// backend because it requires the server-only x-secret-key. +// POST /v1/auth/login/otp — asks Baanx to send the 2FA code for a user whose +// login came back `isOtpRequired`. +export const sendLoginOtpResponseSchema = z.object({ + success: z.boolean().optional().nullable(), +}) +export type SendLoginOtpApiResponse = z.infer + +// GET /api/v3/baanx/oauth/initiate (Pera proxy for Baanx's +// /v1/auth/oauth/authorize/initiate, mode=api). `token` is a 10-minute session +// JWT consumed by the authorize step. The response also carries a hosted-UI +// `url` that API mode never uses, so it is not modeled here. +export const oauthInitiateResponseSchema = z.object({ + token: z.string(), +}) +export type OauthInitiateApiResponse = z.infer< + typeof oauthInitiateResponseSchema +> + +// POST /v1/auth/oauth/authorize — trades the session JWT + login Bearer for a +// single-use authorization code. `state` echoes the CSRF value from initiate. +// (The response's redirect `url` is unused in API mode and not modeled.) +export const oauthAuthorizeResponseSchema = z.object({ + code: z.string(), + state: z.string(), +}) +export type OauthAuthorizeApiResponse = z.infer< + typeof oauthAuthorizeResponseSchema +> + +// POST /v1/auth/oauth/token — both grants return the same shape: a 6h access +// token and a 7-day refresh token (the code grant is proxied via Pera's +// backend, the refresh grant goes direct with x-client-key). export const tokenResponseSchema = z.object({ access_token: z.string(), expires_in: z.number(), diff --git a/packages/card/src/api/auth/transformers.ts b/packages/card/src/api/auth/transformers.ts index 80b6ec462..333294387 100644 --- a/packages/card/src/api/auth/transformers.ts +++ b/packages/card/src/api/auth/transformers.ts @@ -16,8 +16,15 @@ import { VerificationState, type CardSessionTokens, type LoginResult, + type OauthAuthorization, + type OauthInitiation, } from '../../models' -import type { LoginApiResponse, TokenApiResponse } from './schema' +import type { + LoginApiResponse, + OauthAuthorizeApiResponse, + OauthInitiateApiResponse, + TokenApiResponse, +} from './schema' export const transformLoginResponse = ( response: LoginApiResponse, @@ -39,3 +46,16 @@ export const transformTokenResponse = ( accessToken: response.access_token, refreshToken: response.refresh_token, }) + +export const transformOauthInitiateResponse = ( + response: OauthInitiateApiResponse, +): OauthInitiation => ({ + sessionToken: response.token, +}) + +export const transformOauthAuthorizeResponse = ( + response: OauthAuthorizeApiResponse, +): OauthAuthorization => ({ + code: response.code, + state: response.state, +}) diff --git a/packages/card/src/api/transport/__tests__/default-transport.test.ts b/packages/card/src/api/transport/__tests__/default-transport.test.ts index bfa5d79fb..81cac4d5b 100644 --- a/packages/card/src/api/transport/__tests__/default-transport.test.ts +++ b/packages/card/src/api/transport/__tests__/default-transport.test.ts @@ -12,10 +12,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { baanxDirectRequest, queryClient } = vi.hoisted(() => ({ - baanxDirectRequest: vi.fn(), - queryClient: vi.fn(), -})) +const { baanxDirectRequest, queryClient, getValidIntegrityToken, mockConfig } = + vi.hoisted(() => ({ + baanxDirectRequest: vi.fn(), + queryClient: vi.fn(), + getValidIntegrityToken: vi.fn(), + mockConfig: { appEnvironment: 'production' as string }, + })) vi.mock('ky', () => ({ isHTTPError: (error: unknown): boolean => @@ -23,6 +26,10 @@ vi.mock('ky', () => ({ })) vi.mock('../baanx-client', () => ({ baanxDirectRequest })) vi.mock('@perawallet/wallet-core-shared', () => ({ queryClient })) +vi.mock('@perawallet/wallet-core-app-integrity', () => ({ + getValidIntegrityToken, +})) +vi.mock('@perawallet/wallet-core-config', () => ({ config: mockConfig })) import { defaultTransport, setRefreshHandler } from '../default-transport' @@ -33,6 +40,8 @@ describe('defaultTransport', () => { beforeEach(() => { vi.clearAllMocks() setRefreshHandler(null) + getValidIntegrityToken.mockReturnValue(null) + mockConfig.appEnvironment = 'production' }) it('routes direct requests to the Baanx client', async () => { @@ -70,7 +79,81 @@ describe('defaultTransport', () => { expect(baanxDirectRequest).not.toHaveBeenCalled() }) - it('refreshes once and retries on a 401 for direct calls', async () => { + it('attaches the attestation token to proxy calls when one is valid', async () => { + queryClient.mockResolvedValue(ok) + getValidIntegrityToken.mockReturnValue('attestation-token') + + await defaultTransport.request({ + route: 'proxy', + network: 'testnet', + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + }) + + expect(queryClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'x-app-integrity-token': 'attestation-token' }, + }), + ) + }) + + it('falls back to the staging/dev bypass header on non-production builds', async () => { + queryClient.mockResolvedValue(ok) + mockConfig.appEnvironment = 'development' + + await defaultTransport.request({ + route: 'proxy', + network: 'testnet', + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + }) + + expect(queryClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + 'x-bypass-integrity': 'DEVELOPMENT_AND_STAGING_ONLY', + }, + }), + ) + }) + + it('sends neither integrity header on production builds without a token', async () => { + // Production backends reject the bypass header; a missing token must + // surface as the backend's integrity error, not a client-side hack. + queryClient.mockResolvedValue(ok) + + await defaultTransport.request({ + route: 'proxy', + network: 'testnet', + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + }) + + expect(queryClient).toHaveBeenCalledWith( + expect.objectContaining({ headers: {} }), + ) + }) + + it('lets per-request headers win over the integrity defaults', async () => { + queryClient.mockResolvedValue(ok) + getValidIntegrityToken.mockReturnValue('attestation-token') + + await defaultTransport.request({ + route: 'proxy', + network: 'testnet', + method: 'GET', + path: '/api/v3/baanx/oauth/initiate', + headers: { 'x-app-integrity-token': 'explicit-token' }, + }) + + expect(queryClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'x-app-integrity-token': 'explicit-token' }, + }), + ) + }) + + it('refreshes once and retries on a 401 for authenticated calls', async () => { baanxDirectRequest .mockRejectedValueOnce(unauthorized) .mockResolvedValueOnce(ok) @@ -81,6 +164,7 @@ describe('defaultTransport', () => { network: 'mainnet', method: 'GET', path: '/v1/card/status', + authenticated: true, }) expect(res).toEqual(ok) @@ -98,12 +182,33 @@ describe('defaultTransport', () => { network: 'mainnet', method: 'GET', path: '/v1/card/status', + authenticated: true, }), ).rejects.toBe(unauthorized) expect(refresh).toHaveBeenCalledTimes(1) expect(baanxDirectRequest).toHaveBeenCalledTimes(1) }) + it('does not refresh on a 401 from a pre-auth direct call', async () => { + // Login, OTP, and the refresh exchange itself run without the + // keystore Bearer — refreshing can't help, and for the refresh call + // it would recurse. + baanxDirectRequest.mockRejectedValue(unauthorized) + const refresh = vi.fn().mockResolvedValue(true) + setRefreshHandler(refresh) + + await expect( + defaultTransport.request({ + network: 'mainnet', + method: 'POST', + path: '/v1/auth/oauth/token', + data: { grant_type: 'refresh_token' }, + }), + ).rejects.toBe(unauthorized) + expect(refresh).not.toHaveBeenCalled() + expect(baanxDirectRequest).toHaveBeenCalledTimes(1) + }) + it('does not refresh on a proxy 401', async () => { queryClient.mockRejectedValue(unauthorized) const refresh = vi.fn().mockResolvedValue(true) @@ -128,6 +233,7 @@ describe('defaultTransport', () => { network: 'mainnet', method: 'GET', path: '/v1/card/status', + authenticated: true, }), ).rejects.toBe(unauthorized) expect(baanxDirectRequest).toHaveBeenCalledTimes(1) diff --git a/packages/card/src/api/transport/__tests__/registry.test.ts b/packages/card/src/api/transport/__tests__/registry.test.ts index 4941f0288..da9b55689 100644 --- a/packages/card/src/api/transport/__tests__/registry.test.ts +++ b/packages/card/src/api/transport/__tests__/registry.test.ts @@ -15,6 +15,12 @@ import { describe, it, expect, afterEach, vi } from 'vitest' vi.mock('ky', () => ({ isHTTPError: () => false })) vi.mock('../baanx-client', () => ({ baanxDirectRequest: vi.fn() })) vi.mock('@perawallet/wallet-core-shared', () => ({ queryClient: vi.fn() })) +// default-transport pulls the integrity-token reader for proxy headers; stub +// it so the app-integrity store (and its shared/registerStore import) stays +// out of this registry-focused test. +vi.mock('@perawallet/wallet-core-app-integrity', () => ({ + getValidIntegrityToken: () => null, +})) import { getCardTransport, diff --git a/packages/card/src/api/transport/default-transport.ts b/packages/card/src/api/transport/default-transport.ts index 753900732..07b202281 100644 --- a/packages/card/src/api/transport/default-transport.ts +++ b/packages/card/src/api/transport/default-transport.ts @@ -11,6 +11,8 @@ */ import { isHTTPError } from 'ky' +import { getValidIntegrityToken } from '@perawallet/wallet-core-app-integrity' +import { config } from '@perawallet/wallet-core-config' import { queryClient } from '@perawallet/wallet-core-shared' import type { CardTransport, @@ -35,6 +37,25 @@ export const setRefreshHandler = (handler: RefreshHandler | null): void => { const isUnauthorized = (error: unknown): boolean => isHTTPError(error) && error.response?.status === 401 +/** + * The Pera-backend `/api/v3/baanx/*` routes sit behind the app-integrity + * guard, so every proxy call carries the device attestation token when a + * non-expired one is available. On non-production builds (simulators can't + * attest) the backend's env-gated staging/dev bypass header is sent as a + * fallback — production backends ignore it, and production builds never send + * it. + */ +const integrityHeaders = (): Record => { + const integrityToken = getValidIntegrityToken() + if (integrityToken) { + return { 'x-app-integrity-token': integrityToken } + } + if (config.appEnvironment !== 'production') { + return { 'x-bypass-integrity': 'DEVELOPMENT_AND_STAGING_ONLY' } + } + return {} +} + const proxyRequest = ( req: CardTransportRequest, ): Promise> => @@ -46,7 +67,7 @@ const proxyRequest = ( params: req.params, data: req.data, signal: req.signal, - headers: req.headers, + headers: { ...integrityHeaders(), ...req.headers }, responseType: req.responseType, }) @@ -64,10 +85,14 @@ export const defaultTransport: CardTransport = { try { return await dispatch(req) } catch (error) { - // Only direct calls carry the user Bearer. On a 401, refresh once - // and retry; if refresh can't produce a token, surface the error. + // Only `authenticated` calls carry the keystore Bearer, so only + // they can benefit from a refresh: on a 401, refresh once and + // retry; if refresh can't produce a token, surface the error. + // Pre-auth calls (login, OTP) and the refresh exchange itself run + // without the Bearer — intercepting those would be useless at + // best and, for the refresh call, infinitely recursive. if ( - req.route !== 'proxy' && + req.authenticated === true && isUnauthorized(error) && refreshHandler ) { diff --git a/packages/card/src/api/transport/types.ts b/packages/card/src/api/transport/types.ts index 091da100a..5ea98e9b2 100644 --- a/packages/card/src/api/transport/types.ts +++ b/packages/card/src/api/transport/types.ts @@ -15,9 +15,11 @@ import type { Network } from '@perawallet/wallet-core-shared' /** * `direct` (default) → the package-local Baanx client (always x-client-key; the * per-user Bearer is added only when the request sets `authenticated: true`). - * `proxy` → Pera's backend, which attaches the server-only x-secret-key and - * forwards to Baanx. Use `proxy` only for calls that require the secret key - * (e.g. OAuth token exchange / refresh). + * `proxy` → Pera's backend (`/api/v3/baanx/*`), which pins client_id / + * redirect_uri server-side and injects the server-only x-secret-key where + * Baanx requires it. Use `proxy` only for the OAuth initiate and + * authorization-code token exchange; everything else (including the + * refresh-token grant) goes direct. */ export type CardRoute = 'direct' | 'proxy' diff --git a/packages/card/src/hooks/__tests__/onboardingMutations.test.ts b/packages/card/src/hooks/__tests__/onboardingMutations.test.ts index 2bf789648..7827fb373 100644 --- a/packages/card/src/hooks/__tests__/onboardingMutations.test.ts +++ b/packages/card/src/hooks/__tests__/onboardingMutations.test.ts @@ -36,6 +36,9 @@ vi.mock('../../api/onboarding', () => api) const session = vi.hoisted(() => ({ setCardSession: vi.fn() })) vi.mock('../../session', () => session) +const auth = vi.hoisted(() => ({ acquireCardSessionTokens: vi.fn() })) +vi.mock('../../api/auth', () => auth) + import { useSendEmailVerificationMutation } from '../useSendEmailVerificationMutation' import { useVerifyEmailMutation } from '../useVerifyEmailMutation' import { useSendPhoneVerificationMutation } from '../useSendPhoneVerificationMutation' @@ -72,6 +75,11 @@ describe('onboarding mutation hooks', () => { // Consent create returns the set id the hook stashes for the link step. api.submitOnboardingConsent.mockResolvedValue({ consentSetId: 'cs_1' }) session.setCardSession.mockResolvedValue(undefined) + // The registration token is traded for the durable OAuth pair. + auth.acquireCardSessionTokens.mockResolvedValue({ + accessToken: 'oauth-access', + refreshToken: 'oauth-refresh', + }) useCardStore.getState().resetState() }) @@ -216,7 +224,7 @@ describe('onboarding mutation hooks', () => { isSameMailingAddress: true, } - it('useSubmitAddressMutation commits the session token and completes onboarding', async () => { + it('useSubmitAddressMutation exchanges the registration token for the OAuth pair and completes onboarding', async () => { api.submitAddress.mockResolvedValue({ accessToken: 'tok', onboardingId: 'ob_1', @@ -231,6 +239,39 @@ describe('onboarding mutation hooks', () => { address, network: 'mainnet', }) + // The registration-issued token is only used to complete the OAuth + // flow; the persisted session is the durable access+refresh pair. + expect(auth.acquireCardSessionTokens).toHaveBeenCalledWith({ + accessToken: 'tok', + network: 'mainnet', + }) + expect(session.setCardSession).toHaveBeenCalledTimes(1) + expect(session.setCardSession).toHaveBeenCalledWith({ + accessToken: 'oauth-access', + refreshToken: 'oauth-refresh', + }) + expect(useCardStore.getState().onboardingStep).toBe( + OnboardingStep.Completed, + ) + }) + + it('useSubmitAddressMutation persists the fallback pair when the OAuth exchange degrades', async () => { + // acquireCardSessionTokens absorbs exchange failures and returns the + // refresh-less pair — registration completion must not be stranded. + api.submitAddress.mockResolvedValue({ + accessToken: 'tok', + onboardingId: 'ob_1', + }) + auth.acquireCardSessionTokens.mockResolvedValue({ + accessToken: 'tok', + refreshToken: '', + }) + const { result } = renderHook(() => useSubmitAddressMutation(), { + wrapper, + }) + result.current.mutate(address) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(session.setCardSession).toHaveBeenCalledWith({ accessToken: 'tok', refreshToken: '', @@ -253,6 +294,7 @@ describe('onboarding mutation hooks', () => { result.current.mutate(address) await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(auth.acquireCardSessionTokens).not.toHaveBeenCalled() expect(session.setCardSession).not.toHaveBeenCalled() expect(useCardStore.getState().onboardingStep).toBe( OnboardingStep.Completed, diff --git a/packages/card/src/hooks/__tests__/useCardLoginMutation.test.ts b/packages/card/src/hooks/__tests__/useCardLoginMutation.test.ts index de59eefb0..095caa32e 100644 --- a/packages/card/src/hooks/__tests__/useCardLoginMutation.test.ts +++ b/packages/card/src/hooks/__tests__/useCardLoginMutation.test.ts @@ -22,16 +22,21 @@ vi.mock('@perawallet/wallet-core-blockchain', () => ({ const { loginRequest, + acquireCardSessionTokens, setCardSession, fetchOnboardingDetails, setOnboardingId, } = vi.hoisted(() => ({ loginRequest: vi.fn(), + acquireCardSessionTokens: vi.fn(), setCardSession: vi.fn(), fetchOnboardingDetails: vi.fn(), setOnboardingId: vi.fn(), })) -vi.mock('../../api/auth', () => ({ loginRequest })) +vi.mock('../../api/auth', () => ({ + loginRequest, + acquireCardSessionTokens, +})) vi.mock('../../session', () => ({ setCardSession })) vi.mock('../../api/onboarding', () => ({ fetchOnboardingDetails })) vi.mock('../../store', () => ({ @@ -61,15 +66,19 @@ describe('useCardLoginMutation', () => { children, ) - it('logs in with the active network and persists an access-token session', async () => { + it('logs in, completes the OAuth exchange, and persists the token pair', async () => { loginRequest.mockResolvedValue({ - accessToken: 'a', + accessToken: 'ephemeral-login-token', userId: 'u1', isOtpRequired: false, phase: null, verificationState: 'VERIFIED', isLinked: true, }) + acquireCardSessionTokens.mockResolvedValue({ + accessToken: 'oauth-access', + refreshToken: 'oauth-refresh', + }) const { result } = renderHook(() => useCardLoginMutation(), { wrapper }) result.current.mutate({ email: 'e@x.com', password: 'pw' }) @@ -82,18 +91,54 @@ describe('useCardLoginMutation', () => { network: 'mainnet', }), ) - expect(setCardSession).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: 'a', refreshToken: '' }), + // The exchange runs with the ephemeral login token… + expect(acquireCardSessionTokens).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'ephemeral-login-token', + network: 'mainnet', + }), ) + // …and the durable OAuth pair is what gets persisted. + expect(setCardSession).toHaveBeenCalledTimes(1) + expect(setCardSession).toHaveBeenCalledWith({ + accessToken: 'oauth-access', + refreshToken: 'oauth-refresh', + }) // A complete account never touches the onboarding bridge. expect(fetchOnboardingDetails).not.toHaveBeenCalled() expect(setOnboardingId).not.toHaveBeenCalled() }) + it('persists the fallback pair when the OAuth exchange degrades', async () => { + // acquireCardSessionTokens absorbs exchange failures and returns the + // refresh-less pair — the login itself must still succeed. + loginRequest.mockResolvedValue({ + accessToken: 'ephemeral-login-token', + userId: 'u1', + isOtpRequired: false, + phase: null, + verificationState: 'VERIFIED', + isLinked: true, + }) + acquireCardSessionTokens.mockResolvedValue({ + accessToken: 'ephemeral-login-token', + refreshToken: '', + }) + + const { result } = renderHook(() => useCardLoginMutation(), { wrapper }) + result.current.mutate({ email: 'e@x.com', password: 'pw' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(setCardSession).toHaveBeenCalledWith({ + accessToken: 'ephemeral-login-token', + refreshToken: '', + }) + }) + it('does not persist a session while OTP is still required', async () => { loginRequest.mockResolvedValue({ accessToken: null, - userId: null, + userId: 'u1', isOtpRequired: true, phase: null, verificationState: null, @@ -104,10 +149,12 @@ describe('useCardLoginMutation', () => { result.current.mutate({ email: 'e@x.com', password: 'pw' }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(acquireCardSessionTokens).not.toHaveBeenCalled() expect(setCardSession).not.toHaveBeenCalled() expect(fetchOnboardingDetails).not.toHaveBeenCalled() expect(setOnboardingId).not.toHaveBeenCalled() expect(result.current.data?.isOtpRequired).toBe(true) + expect(result.current.data?.tokens).toBeNull() }) it('resolves KYC state and bridges userId to onboardingId for a mid-onboarding login', async () => { diff --git a/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts b/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts new file mode 100644 index 000000000..e090c4bbb --- /dev/null +++ b/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts @@ -0,0 +1,75 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' + +const mockUseNetwork = vi.hoisted(() => vi.fn()) +vi.mock('@perawallet/wallet-core-blockchain', () => ({ + useNetwork: mockUseNetwork, +})) + +const { sendLoginOtpRequest } = vi.hoisted(() => ({ + sendLoginOtpRequest: vi.fn(), +})) +vi.mock('../../api/auth', () => ({ sendLoginOtpRequest })) + +import { useSendLoginOtpMutation } from '../useSendLoginOtpMutation' + +describe('useSendLoginOtpMutation', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + vi.clearAllMocks() + mockUseNetwork.mockReturnValue({ network: 'mainnet' }) + }) + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + QueryClientProvider, + { client: queryClient }, + children, + ) + + it('requests the login OTP for the user on the active network', async () => { + sendLoginOtpRequest.mockResolvedValue(undefined) + + const { result } = renderHook(() => useSendLoginOtpMutation(), { + wrapper, + }) + result.current.mutate({ userId: 'user-1' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(sendLoginOtpRequest).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', network: 'mainnet' }), + ) + }) + + it('surfaces a send failure as a mutation error', async () => { + sendLoginOtpRequest.mockRejectedValue(new Error('boom')) + + const { result } = renderHook(() => useSendLoginOtpMutation(), { + wrapper, + }) + result.current.mutate({ userId: 'user-1' }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) +}) diff --git a/packages/card/src/hooks/index.ts b/packages/card/src/hooks/index.ts index 8de76b64f..990d04e25 100644 --- a/packages/card/src/hooks/index.ts +++ b/packages/card/src/hooks/index.ts @@ -13,6 +13,7 @@ // Session / auth export * from './useCardSession' export * from './useCardLoginMutation' +export * from './useSendLoginOtpMutation' export * from './useCardLogout' // Card lifecycle + status diff --git a/packages/card/src/hooks/useCardLoginMutation.ts b/packages/card/src/hooks/useCardLoginMutation.ts index f6e49242a..f552d1d05 100644 --- a/packages/card/src/hooks/useCardLoginMutation.ts +++ b/packages/card/src/hooks/useCardLoginMutation.ts @@ -12,12 +12,12 @@ import { useMutation } from '@tanstack/react-query' import { useNetwork } from '@perawallet/wallet-core-blockchain' -import { logger } from '@perawallet/wallet-core-shared' -import { loginRequest } from '../api/auth' +import { logger, type Nullable } from '@perawallet/wallet-core-shared' +import { acquireCardSessionTokens, loginRequest } from '../api/auth' import { fetchOnboardingDetails } from '../api/onboarding' import { setCardSession } from '../session' import { useCardStore } from '../store' -import type { LoginResult } from '../models' +import type { CardSessionTokens, LoginResult } from '../models' import { toCardMutationResult, type CardMutationResult } from './types' export type CardLoginParams = { @@ -26,17 +26,40 @@ export type CardLoginParams = { otpCode?: string } +/** + * Login outcome plus the durable OAuth token pair. `tokens` is set only when + * credentials (and OTP, if required) were accepted AND the OAuth exchange + * completed; it is null while OTP or onboarding is still pending. + */ +export type CardLoginData = LoginResult & { + tokens: Nullable +} + export type UseCardLoginMutationResult = CardMutationResult< CardLoginParams, - LoginResult + CardLoginData > export const useCardLoginMutation = (): UseCardLoginMutationResult => { const { network } = useNetwork() - const mutation = useMutation({ + const mutation = useMutation({ mutationFn: async params => { const result = await loginRequest({ ...params, network }) + // Credentials accepted: the returned access token is the ephemeral + // 6h OAuth-completion token. Trade it for the durable pair (6h + // access + 7-day refresh) so the session can be silently refreshed + // instead of forcing a re-login every 6 hours. On an exchange + // failure this degrades to a refresh-less 6h session rather than + // failing a login whose credentials (and OTP) were already + // accepted — an OAuth-proxy outage must not become a login outage. + if (result.accessToken) { + const tokens = await acquireCardSessionTokens({ + accessToken: result.accessToken, + network, + }) + return { ...result, tokens } + } // A mid-onboarding login issues no access token and an unreliable // (often null) verificationState. Treat userId as the onboardingId // and read the real KYC state from the pre-auth onboarding endpoint @@ -44,7 +67,6 @@ export const useCardLoginMutation = (): UseCardLoginMutationResult => { // failure (the caller treats a null state as unverified). // TODO(card): confirm Baanx accepts userId as the onboardingId here. if ( - !result.accessToken && result.phase !== null && result.userId !== null && result.verificationState === null @@ -54,29 +76,25 @@ export const useCardLoginMutation = (): UseCardLoginMutationResult => { onboardingId: result.userId, network, }) - return { ...result, verificationState } + return { ...result, verificationState, tokens: null } } catch (error) { // Best-effort: keep the login result so the caller can still // resume onboarding (a null state is treated as unverified). // Log it so a wrong userId->onboardingId assumption surfaces // instead of silently mis-routing. logger.warn('Card login KYC-state lookup failed', { error }) - return result + return { ...result, tokens: null } } } - return result + return { ...result, tokens: null } }, throwOnError: false, onSuccess: async result => { - // A null access token means OTP is still required or registration - // is unfinished. Direct login has no refresh token (only the OAuth - // flow issues one), so a 401 later can't be refreshed and the user - // is routed back to login. - if (result.accessToken) { - await setCardSession({ - accessToken: result.accessToken, - refreshToken: '', - }) + // Persist only the durable OAuth pair — never the ephemeral login + // token. Null tokens mean OTP is still required or registration is + // unfinished. + if (result.tokens) { + await setCardSession(result.tokens) return } // Mid-onboarding: bridge userId -> onboardingId so the resumed diff --git a/packages/card/src/hooks/useSendLoginOtpMutation.ts b/packages/card/src/hooks/useSendLoginOtpMutation.ts new file mode 100644 index 000000000..58fc94f64 --- /dev/null +++ b/packages/card/src/hooks/useSendLoginOtpMutation.ts @@ -0,0 +1,40 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useMutation } from '@tanstack/react-query' +import { useNetwork } from '@perawallet/wallet-core-blockchain' +import { sendLoginOtpRequest } from '../api/auth' +import { toCardMutationResult, type CardMutationResult } from './types' + +export type SendLoginOtpParams = { + /** `userId` from the login attempt that came back `isOtpRequired`. */ + userId: string +} + +export type UseSendLoginOtpMutationResult = + CardMutationResult + +/** + * Asks Baanx to send the login 2FA code. Baanx does not send it on its own — + * call this when login returns `isOtpRequired` (and again on "resend"), then + * retry the login with the user-entered `otpCode`. + */ +export const useSendLoginOtpMutation = (): UseSendLoginOtpMutationResult => { + const { network } = useNetwork() + + const mutation = useMutation({ + mutationFn: ({ userId }) => sendLoginOtpRequest({ userId, network }), + throwOnError: false, + }) + + return toCardMutationResult(mutation) +} diff --git a/packages/card/src/hooks/useSubmitAddressMutation.ts b/packages/card/src/hooks/useSubmitAddressMutation.ts index 47b4e27e9..549c742c3 100644 --- a/packages/card/src/hooks/useSubmitAddressMutation.ts +++ b/packages/card/src/hooks/useSubmitAddressMutation.ts @@ -13,6 +13,7 @@ import { useMutation } from '@tanstack/react-query' import { useNetwork } from '@perawallet/wallet-core-blockchain' import { logger } from '@perawallet/wallet-core-shared' +import { acquireCardSessionTokens } from '../api/auth' import { submitAddress, type SubmitAddressResult } from '../api/onboarding' import { getCardApiError } from '../api/errors' import { OnboardingStep, type AddressInput } from '../models' @@ -30,9 +31,10 @@ export const useSubmitAddressMutation = (): UseSubmitAddressMutationResult => { const mutation = useMutation({ mutationFn: address => submitAddress({ address, network }), - // The address step finalizes registration and issues the bearer token; - // commit it (no refresh token at registration, like direct login) so - // the post-onboarding user endpoints work, then mark onboarding done. + // The address step finalizes registration and issues the same class of + // 6h user access token as login. Trade it for the durable OAuth pair + // (6h access + 7-day refresh) so the brand-new session can be silently + // refreshed, then mark onboarding done. onSuccess: async result => { // accessToken is null only on the US separate-mailing path, which we // don't yet collect (the address screen always sends @@ -40,10 +42,14 @@ export const useSubmitAddressMutation = (): UseSubmitAddressMutationResult => { // TODO(card): the US mailing-address step will issue the token; until // then accessToken is always present here. if (result.accessToken) { - await setCardSession({ + // Falls back to a refresh-less 6h session if the OAuth + // exchange fails — registration already succeeded and must + // not be stranded on an exchange outage. + const tokens = await acquireCardSessionTokens({ accessToken: result.accessToken, - refreshToken: '', + network, }) + await setCardSession(tokens) } useCardStore.getState().setOnboardingStep(OnboardingStep.Completed) }, diff --git a/packages/card/src/models/session.ts b/packages/card/src/models/session.ts index 197d22244..c81c36ebe 100644 --- a/packages/card/src/models/session.ts +++ b/packages/card/src/models/session.ts @@ -22,17 +22,35 @@ export type CardSession = { } /** - * Transit-only token bundle. The full access+refresh pair comes from the OAuth - * token endpoint (`POST /v1/auth/oauth/token`); direct login yields only an - * access token (no refresh). Written straight to the KMS keystore; never held + * Transit-only token bundle from the OAuth token endpoint + * (`POST /v1/auth/oauth/token`): a 6h access token plus a 7-day refresh token + * (both rotate on refresh). Written straight to the KMS keystore; never held * in app memory or persisted to a Zustand store. */ export type CardSessionTokens = { accessToken: string - /** Empty string for direct-login sessions (no refresh token issued). */ + /** 7-day OAuth refresh token, exchanged on 401 to keep the user signed in. */ refreshToken: string } +/** + * OAuth step 1 (`GET /api/v3/baanx/oauth/initiate`, proxied): a 10-minute + * session JWT that the authorize step trades for an authorization code. + */ +export type OauthInitiation = { + sessionToken: string +} + +/** + * OAuth step 3 (`POST /v1/auth/oauth/authorize`): single-use authorization + * code plus the echoed CSRF `state`, which callers MUST compare against the + * value they sent to initiate. + */ +export type OauthAuthorization = { + code: string + state: string +} + export const OnboardingPhase = { Account: 'ACCOUNT', PhoneNumber: 'PHONE_NUMBER', @@ -44,9 +62,11 @@ export type OnboardingPhase = (typeof OnboardingPhase)[keyof typeof OnboardingPhase] /** - * Outcome of POST /v1/auth/login. Returns only an access token (6h, no refresh - * token — those come from the OAuth flow). `accessToken` is null when OTP is - * still required; `phase` is set only mid-onboarding. + * Outcome of POST /v1/auth/login (OAuth step 2). `accessToken` is the + * ephemeral 6h token used ONLY to complete the OAuth authorize step — it is + * never persisted; the durable session pair comes from the token exchange. + * `accessToken` is null when OTP is still required; `phase` is set only + * mid-onboarding. */ export type LoginResult = { accessToken: Nullable diff --git a/packages/card/src/session/__tests__/session.test.ts b/packages/card/src/session/__tests__/session.test.ts index 960b1c3d2..f8bc67c13 100644 --- a/packages/card/src/session/__tests__/session.test.ts +++ b/packages/card/src/session/__tests__/session.test.ts @@ -98,6 +98,18 @@ describe('card session', () => { ) }) + it('removes a stale refresh secret when the new session has none', async () => { + // A fallback (refresh-less) session must not leave an earlier + // session's refresh token behind — refreshSession would otherwise + // exchange a stale credential against the new access token. + await setCardSession({ accessToken: 'a1', refreshToken: 'r1' }) + + await setCardSession({ accessToken: 'a2', refreshToken: '' }) + + expect(removeSecret).toHaveBeenCalledWith('baanx-refresh-token') + expect(hasSecret('baanx-refresh-token')).toBe(false) + }) + it('never writes tokens to the persisted session store', async () => { await setCardSession({ accessToken: 'super-secret', refreshToken: 'r' }) diff --git a/packages/card/src/session/session.ts b/packages/card/src/session/session.ts index 598cc882a..8e24a8ea8 100644 --- a/packages/card/src/session/session.ts +++ b/packages/card/src/session/session.ts @@ -43,7 +43,13 @@ const commitTokenSecret = async (id: string, token: string): Promise => { /** * Persists tokens to the encrypted keystore and flips the auth flag. The tokens * are never cached in app memory — they are read back from the keystore on - * demand (see baanx-client). Direct-login sessions pass an empty refreshToken. + * demand (see baanx-client). Both login and registration finalize complete the + * OAuth exchange, so sessions normally carry a refresh token; an empty + * refreshToken occurs only via the exchange-failure fallback + * (acquireCardSessionTokens) and yields a session that logs out on its first + * 401. An empty refreshToken also REMOVES any prior refresh secret — a stale + * one from an earlier session must never be exchanged against this session's + * access token. */ export const setCardSession = async ( tokens: CardSessionTokens, @@ -51,6 +57,8 @@ export const setCardSession = async ( await commitTokenSecret(ACCESS_TOKEN_SECRET_ID, tokens.accessToken) if (tokens.refreshToken) { await commitTokenSecret(REFRESH_TOKEN_SECRET_ID, tokens.refreshToken) + } else { + await removeSecret(REFRESH_TOKEN_SECRET_ID) } useCardSessionStore.getState().setAuthenticated(true) } @@ -73,8 +81,9 @@ export const clearCardSession = async (): Promise => { * Refresh handler invoked by the transport on a 401. Reads the refresh token * from the keystore and exchanges it (decoded only inside the `withSecret` * handler). Returns whether a usable session is now in place; on any failure - * (including no refresh token — e.g. a direct-login session) it clears the - * session so the UI routes the user back to login. + * (including no refresh token — e.g. a registration-finalize session that + * predates the OAuth exchange) it clears the session so the UI routes the + * user back to login. */ export const refreshSession = async (): Promise => { try { diff --git a/packages/card/src/test-handlers.ts b/packages/card/src/test-handlers.ts index 138e24b04..ef1f61e5d 100644 --- a/packages/card/src/test-handlers.ts +++ b/packages/card/src/test-handlers.ts @@ -14,10 +14,23 @@ // them into the production entry (src/index.ts). Consumed only via the test // alias `@perawallet/wallet-core-card/test-handlers`. -export { mockLogin, mockRefreshToken } from './api/auth/msw-handlers' +export { + mockLogin, + mockOauthAuthorize, + mockOauthChain, + mockOauthInitiate, + mockOauthToken, + mockRefreshToken, + mockSendLoginOtp, +} from './api/auth/msw-handlers' export type { MockLoginParams, + MockOauthAuthorizeParams, + MockOauthChainParams, + MockOauthInitiateParams, + MockOauthTokenParams, MockRefreshTokenParams, + MockSendLoginOtpParams, } from './api/auth/msw-handlers' export { diff --git a/packages/fee-delegation/src/hooks/__tests__/useFeeDelegation.spec.tsx b/packages/fee-delegation/src/hooks/__tests__/useFeeDelegation.spec.tsx index 5f4ed57f7..4acc7c558 100644 --- a/packages/fee-delegation/src/hooks/__tests__/useFeeDelegation.spec.tsx +++ b/packages/fee-delegation/src/hooks/__tests__/useFeeDelegation.spec.tsx @@ -25,12 +25,12 @@ import { const { addSignRequestMock, submitAndAutoRefreshMock, - getAppIntegrityStateMock, + getValidIntegrityTokenMock, requestFeeDelegationMock, } = vi.hoisted(() => ({ addSignRequestMock: vi.fn(), submitAndAutoRefreshMock: vi.fn(), - getAppIntegrityStateMock: vi.fn(), + getValidIntegrityTokenMock: vi.fn(), requestFeeDelegationMock: vi.fn(), })) @@ -66,8 +66,11 @@ vi.mock('@perawallet/wallet-core-signing', () => ({ submitAndAutoRefresh: submitAndAutoRefreshMock, })) +// The hook only branches on token-present vs null; expiry semantics belong to +// getValidIntegrityToken itself (unit-tested in the app-integrity package), +// so the mock stubs its return value directly. vi.mock('@perawallet/wallet-core-app-integrity', () => ({ - useAppIntegrityStore: { getState: () => getAppIntegrityStateMock() }, + getValidIntegrityToken: getValidIntegrityTokenMock, })) vi.mock('../../api', () => ({ @@ -87,10 +90,7 @@ const ASSET_ID = 31566704n // Inputs are ASCII, so btoa is byte-exact and needs no Node Buffer global. const toBase64 = (text: string) => btoa(text) -const validAttestation = () => ({ - integrityToken: 'valid-token', - expiresAt: new Date(Date.now() + 60_000).toISOString(), -}) +const VALID_TOKEN = 'valid-token' const baseParams = { account: ACCOUNT, @@ -110,29 +110,14 @@ function renderDelegation() { beforeEach(() => { vi.clearAllMocks() - getAppIntegrityStateMock.mockReturnValue(validAttestation()) + getValidIntegrityTokenMock.mockReturnValue(VALID_TOKEN) }) describe('fee-delegation/useFeeDelegation', () => { - test('rejects with FeeDelegationAttestationRequiredError when no token is stored', async () => { - getAppIntegrityStateMock.mockReturnValue({ - integrityToken: null, - expiresAt: null, - }) - - const result = renderDelegation() - - await expect( - result.current.submitWithFeeDelegation(baseParams), - ).rejects.toBeInstanceOf(FeeDelegationAttestationRequiredError) - expect(requestFeeDelegationMock).not.toHaveBeenCalled() - }) - - test('rejects when the stored token has expired', async () => { - getAppIntegrityStateMock.mockReturnValue({ - integrityToken: 'stale-token', - expiresAt: new Date(Date.now() - 1_000).toISOString(), - }) + // Expiry semantics live in getValidIntegrityToken's own unit tests + // (app-integrity package); the hook only branches on token vs null. + test('rejects with FeeDelegationAttestationRequiredError when no usable token exists', async () => { + getValidIntegrityTokenMock.mockReturnValue(null) const result = renderDelegation() diff --git a/packages/fee-delegation/src/hooks/useFeeDelegation.ts b/packages/fee-delegation/src/hooks/useFeeDelegation.ts index e7d88ae89..29a7b50d9 100644 --- a/packages/fee-delegation/src/hooks/useFeeDelegation.ts +++ b/packages/fee-delegation/src/hooks/useFeeDelegation.ts @@ -25,12 +25,11 @@ import { useSigningRequest, } from '@perawallet/wallet-core-signing' import type { TransactionSignRequest } from '@perawallet/wallet-core-signing' -import { useAppIntegrityStore } from '@perawallet/wallet-core-app-integrity' +import { getValidIntegrityToken } from '@perawallet/wallet-core-app-integrity' import { decodeFromBase64, encodeToBase64, generateOrderedUniqueId, - type Nullable, } from '@perawallet/wallet-core-shared' import { requestFeeDelegation } from '../api' @@ -70,22 +69,6 @@ type GroupSlot = | { kind: 'preSigned'; signed: PeraSignedTransaction } | { kind: 'toSign'; flatIndex: number } -/** - * The current non-expired device attestation token, or null when absent or - * expired. Read synchronously from the store snapshot so the decision is made - * at call time, not at render time. - */ -const getValidIntegrityToken = (): Nullable => { - const { integrityToken, expiresAt } = useAppIntegrityStore.getState() - if (!integrityToken || !expiresAt) { - return null - } - const expiry = Date.parse(expiresAt) - return Number.isFinite(expiry) && expiry > Date.now() - ? integrityToken - : null -} - /** * Hand the wallet-signable slot(s) to the signing pipeline as a headless * callback request and resolve with the signed bytes. `groupContext` is the diff --git a/packages/passkeys/src/hooks/__tests__/usePasskeysQuery.spec.ts b/packages/passkeys/src/hooks/__tests__/usePasskeysQuery.spec.ts index 124de1b97..5c89d95f2 100644 --- a/packages/passkeys/src/hooks/__tests__/usePasskeysQuery.spec.ts +++ b/packages/passkeys/src/hooks/__tests__/usePasskeysQuery.spec.ts @@ -56,7 +56,12 @@ vi.mock('@perawallet/wallet-extension-provider', () => ({ getKeystoreStore: () => keystoreStore, })) -vi.mock('@perawallet/wallet-core-shared', () => ({ +vi.mock('@perawallet/wallet-core-shared', async importOriginal => ({ + // Keep the real utils (passkey.ts uses toUrlSafeBase64); stub only the + // logger so assertions stay silent. + ...(await importOriginal< + typeof import('@perawallet/wallet-core-shared') + >()), logger: { warn: vi.fn(), error: vi.fn() }, })) diff --git a/packages/passkeys/src/models/__tests__/passkey.spec.ts b/packages/passkeys/src/models/__tests__/passkey.spec.ts index 83bdce7a8..c72cdc239 100644 --- a/packages/passkeys/src/models/__tests__/passkey.spec.ts +++ b/packages/passkeys/src/models/__tests__/passkey.spec.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest' import type { Key } from '@algorandfoundation/keystore' import type { NativeStoredCredential } from '@perawallet/wallet-extension-passkey-autofill' -import { credentialToPasskey, keyToPasskey, toUrlSafeBase64 } from '../passkey' +import { credentialToPasskey, keyToPasskey } from '../passkey' const buildPasskeyKey = (metadata: Record): Key => ({ @@ -33,19 +33,8 @@ const buildNativeCredential = ( ...overrides, }) as NativeStoredCredential -describe('toUrlSafeBase64', () => { - it('replaces + and / with url-safe characters and strips trailing padding', () => { - expect(toUrlSafeBase64('ab+/cd==')).toBe('ab-_cd') - }) - - it('leaves a fully padding-free string unchanged aside from char swaps', () => { - expect(toUrlSafeBase64('abcd')).toBe('abcd') - }) - - it('only strips trailing padding, not interior = characters', () => { - expect(toUrlSafeBase64('a=b=')).toBe('a=b') - }) -}) +// toUrlSafeBase64 unit tests live in @perawallet/wallet-core-shared +// (utils/__tests__/strings.test.ts) — the helper moved there. describe('keyToPasskey', () => { it('uses the native userHandle (WebAuthn user.name) as the display name when no explicit name is stored', () => { diff --git a/packages/passkeys/src/models/passkey.ts b/packages/passkeys/src/models/passkey.ts index c2f7002a9..061390054 100644 --- a/packages/passkeys/src/models/passkey.ts +++ b/packages/passkeys/src/models/passkey.ts @@ -11,6 +11,7 @@ */ import type { Key } from '@algorandfoundation/keystore' +import { toUrlSafeBase64 } from '@perawallet/wallet-core-shared' import type { NativeStoredCredential } from '@perawallet/wallet-extension-passkey-autofill' /** @@ -71,17 +72,6 @@ export const isPasskeyKey = (key: Key): boolean => export const isPasskeyAlgorithm = (algorithm: string | undefined): boolean => algorithm === 'P256' -/** - * Converts a standard base64 string to url-safe base64 (no padding). - */ -export const toUrlSafeBase64 = (b64: string): string => { - // Strip trailing '=' padding with a linear scan rather than a regex: - // /=+$/ backtracks polynomially on long runs of '=' (ReDoS). - let end = b64.length - while (end > 0 && b64.charCodeAt(end - 1) === 0x3d /* '=' */) end-- - return b64.slice(0, end).replace(/\+/g, '-').replace(/\//g, '_') -} - const readString = ( m: Record, k: string, diff --git a/packages/shared/src/utils/__tests__/strings.test.ts b/packages/shared/src/utils/__tests__/strings.test.ts index 03454d9d2..36489949e 100644 --- a/packages/shared/src/utils/__tests__/strings.test.ts +++ b/packages/shared/src/utils/__tests__/strings.test.ts @@ -12,7 +12,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import { Decimal } from 'decimal.js' -import { encodeToBase64, decodeFromBase64 } from '../strings' +import { encodeToBase64, decodeFromBase64, toUrlSafeBase64 } from '../strings' import { hexToBytes, bytesToHex, utf8ByteLength } from '../strings' import { decodeLongString, @@ -48,6 +48,32 @@ describe('utils/strings - base64 encoding', () => { }) }) +describe('utils/strings - toUrlSafeBase64', () => { + test('maps +/ to -_ and strips padding', () => { + expect(toUrlSafeBase64('ab+/cd==')).toBe('ab-_cd') + }) + + test('leaves an already url-safe string unchanged', () => { + expect(toUrlSafeBase64('abcd')).toBe('abcd') + }) + + test('strips only trailing padding, preserving inner =', () => { + expect(toUrlSafeBase64('a=b=')).toBe('a=b') + }) + + test('handles empty and all-padding inputs', () => { + expect(toUrlSafeBase64('')).toBe('') + expect(toUrlSafeBase64('==')).toBe('') + }) + + test('produces valid base64url from encoded bytes', () => { + // 0xfb 0xff → '+/8=' in standard base64; url-safe form is '-_8'. + expect(toUrlSafeBase64(encodeToBase64(new Uint8Array([251, 255])))).toBe( + '-_8', + ) + }) +}) + describe('utils/strings - hex encoding', () => { test('hexToBytes converts hex string to Uint8Array', () => { expect(Array.from(hexToBytes('00ff10'))).toEqual([0, 255, 16]) diff --git a/packages/shared/src/utils/strings.ts b/packages/shared/src/utils/strings.ts index e98a5b107..9413c6778 100644 --- a/packages/shared/src/utils/strings.ts +++ b/packages/shared/src/utils/strings.ts @@ -23,6 +23,18 @@ export const decodeFromBase64 = (base64: string): Uint8Array => { return toByteArray(base64) } +/** + * Converts a standard base64 string to url-safe base64 (RFC 4648 §5, no + * padding): `+` → `-`, `/` → `_`, trailing `=` removed. + */ +export const toUrlSafeBase64 = (b64: string): string => { + // Strip trailing '=' padding with a linear scan rather than a regex: + // /=+$/ backtracks polynomially on long runs of '=' (ReDoS). + let end = b64.length + while (end > 0 && b64.charCodeAt(end - 1) === 0x3d /* '=' */) end-- + return b64.slice(0, end).replace(/\+/g, '-').replace(/\//g, '_') +} + export const hexToBytes = (hex: string): Uint8Array => { const bytes = new Uint8Array(hex.length / 2) for (let i = 0; i < hex.length; i += 2) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3186d27d..9b9c8e7f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1821,6 +1821,12 @@ importers: '@babel/runtime': specifier: 'catalog:' version: 7.29.7 + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 + '@perawallet/wallet-core-app-integrity': + specifier: workspace:* + version: link:../app-integrity '@perawallet/wallet-core-blockchain': specifier: workspace:* version: link:../blockchain @@ -1881,7 +1887,7 @@ importers: version: 5.0.3(esbuild@0.28.1)(rolldown@1.1.3)(typescript@5.9.3)(vite@8.1.0(@types/node@26.1.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.8.3)) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.0)(typescript@5.9.3))(vite@8.1.0(@types/node@26.1.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.8.3)) + version: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@26.1.0)(typescript@5.9.3))(vite@8.1.0(@types/node@26.1.0)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.8.3)) packages/config: dependencies: From b5543ded9ed7b4abd3546615bfc7ac4048189dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20=C3=87al=C4=B1=C5=9Fkan?= Date: Fri, 17 Jul 2026 20:15:11 +0300 Subject: [PATCH 2/2] fix: linter issues --- packages/card/src/api/auth/__tests__/oauth-login.test.ts | 2 +- packages/card/src/api/auth/__tests__/pkce.test.ts | 2 +- packages/card/src/api/auth/msw-handlers.ts | 5 +---- packages/card/src/api/auth/oauth-login.ts | 2 +- packages/card/src/api/auth/pkce.ts | 2 +- .../src/hooks/__tests__/useSendLoginOtpMutation.test.ts | 2 +- packages/card/src/hooks/useSendLoginOtpMutation.ts | 2 +- packages/shared/src/utils/__tests__/strings.test.ts | 6 +++--- 8 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/card/src/api/auth/__tests__/oauth-login.test.ts b/packages/card/src/api/auth/__tests__/oauth-login.test.ts index 1fb5c8387..d33800691 100644 --- a/packages/card/src/api/auth/__tests__/oauth-login.test.ts +++ b/packages/card/src/api/auth/__tests__/oauth-login.test.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/card/src/api/auth/__tests__/pkce.test.ts b/packages/card/src/api/auth/__tests__/pkce.test.ts index 1dced895f..6507eb908 100644 --- a/packages/card/src/api/auth/__tests__/pkce.test.ts +++ b/packages/card/src/api/auth/__tests__/pkce.test.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/card/src/api/auth/msw-handlers.ts b/packages/card/src/api/auth/msw-handlers.ts index 52641698f..5a22cf246 100644 --- a/packages/card/src/api/auth/msw-handlers.ts +++ b/packages/card/src/api/auth/msw-handlers.ts @@ -164,10 +164,7 @@ export const mockOauthChain = ({ ) }), http.post('*/v1/auth/oauth/authorize', () => - HttpResponse.json( - { code, state: capturedState }, - { status: 200 }, - ), + HttpResponse.json({ code, state: capturedState }, { status: 200 }), ), http.post('*/baanx/oauth/token', () => HttpResponse.json(tokenResponse, { status: 200 }), diff --git a/packages/card/src/api/auth/oauth-login.ts b/packages/card/src/api/auth/oauth-login.ts index b84f93a42..eb3b1e060 100644 --- a/packages/card/src/api/auth/oauth-login.ts +++ b/packages/card/src/api/auth/oauth-login.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/card/src/api/auth/pkce.ts b/packages/card/src/api/auth/pkce.ts index bdadc8945..7f373522f 100644 --- a/packages/card/src/api/auth/pkce.ts +++ b/packages/card/src/api/auth/pkce.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts b/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts index e090c4bbb..d6e685d54 100644 --- a/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts +++ b/packages/card/src/hooks/__tests__/useSendLoginOtpMutation.test.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/card/src/hooks/useSendLoginOtpMutation.ts b/packages/card/src/hooks/useSendLoginOtpMutation.ts index 58fc94f64..6af5bf26c 100644 --- a/packages/card/src/hooks/useSendLoginOtpMutation.ts +++ b/packages/card/src/hooks/useSendLoginOtpMutation.ts @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Pera Wallet, LDA + Copyright 2022-2026 Pera Wallet, LDA Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 diff --git a/packages/shared/src/utils/__tests__/strings.test.ts b/packages/shared/src/utils/__tests__/strings.test.ts index 36489949e..abfc70d9a 100644 --- a/packages/shared/src/utils/__tests__/strings.test.ts +++ b/packages/shared/src/utils/__tests__/strings.test.ts @@ -68,9 +68,9 @@ describe('utils/strings - toUrlSafeBase64', () => { test('produces valid base64url from encoded bytes', () => { // 0xfb 0xff → '+/8=' in standard base64; url-safe form is '-_8'. - expect(toUrlSafeBase64(encodeToBase64(new Uint8Array([251, 255])))).toBe( - '-_8', - ) + expect( + toUrlSafeBase64(encodeToBase64(new Uint8Array([251, 255]))), + ).toBe('-_8') }) })