Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: unit tests

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Sync SvelteKit
run: npm run prepare --workspaces --if-present

- name: Run unit tests
run: npm run test:unit --workspaces --if-present -- --run
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ build
.claude
security-scan.log
security-fix.log
.idea
*.code-workspace
2 changes: 1 addition & 1 deletion apps/api-manager/src/lib/components/Toast.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { toaster } from '$lib/utils/toastService';
</script>

<Toast.Group {toaster}>
<Toast.Group {toaster} placement="top-end" class="fixed top-4 right-4 z-[9999] flex flex-col gap-2">
{#snippet children(toast)}
<Toast {toast}>
<Toast.Message>
Expand Down
57 changes: 57 additions & 0 deletions apps/api-manager/src/lib/oauth/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OAuth2Client } from 'arctic';
import { OAuth2ClientWithConfig } from './client';

// Build a JWT-shaped token (header.payload.signature) with a base64 payload.
function createMockJWT(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.mock-signature`;
}

describe('OAuth2ClientWithConfig', () => {
let client: OAuth2ClientWithConfig;

beforeEach(() => {
// arctic's OAuth2Client is globally mocked to return a plain object, which
// would hijack `this` in the subclass constructor and strip its prototype
// methods. Reset it to a no-op constructor so the real subclass instance is
// built.
vi.mocked(OAuth2Client).mockImplementation(function () {} as never);
client = new OAuth2ClientWithConfig('id', 'secret', 'http://localhost/callback');
});

describe('constructor', () => {
it('creates an instance exposing the custom methods', () => {
expect(client.OIDCConfig).toBeUndefined();
expect(typeof client.checkAccessTokenExpiration).toBe('function');
});
});

describe('checkAccessTokenExpiration (fail closed)', () => {
it('returns false for a token whose expiry is in the future', async () => {
const token = createMockJWT({ exp: Math.floor(Date.now() / 1000) + 3600 });
await expect(client.checkAccessTokenExpiration(token)).resolves.toBe(false);
});

it('returns true for an expired token', async () => {
const token = createMockJWT({ exp: Math.floor(Date.now() / 1000) - 3600 });
await expect(client.checkAccessTokenExpiration(token)).resolves.toBe(true);
});

it('treats a token without an exp claim as expired', async () => {
const token = createMockJWT({ sub: 'user-1' });
await expect(client.checkAccessTokenExpiration(token)).resolves.toBe(true);
});

it('treats an undecodable token as expired instead of throwing', async () => {
await expect(client.checkAccessTokenExpiration('not.a.jwt')).resolves.toBe(true);
});

it('treats a malformed JWT payload as expired instead of throwing', async () => {
await expect(
client.checkAccessTokenExpiration('eyJhbGciOiJSUzI1NiJ9.invalid-payload.sig')
).resolves.toBe(true);
});
});
});
7 changes: 0 additions & 7 deletions apps/api-manager/src/lib/oauth/sessionHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ export class SessionOAuthHelper {
logger.debug(` Access token present: ${!!oauthData?.access_token}`);
logger.debug(` Refresh token present: ${!!oauthData?.refresh_token}`);

if (oauthData?.access_token) {
logger.debug(` Access token length: ${oauthData.access_token.length}`);
logger.debug(
` Access token preview: ${oauthData.access_token.substring(0, 30)}...`,
);
}

if (!oauthData?.provider || !oauthData?.access_token) {
logger.warn("🔐 SESSION OAUTH - Missing provider or access token");
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
)}
<tr>
<td class="cell-mono cell-id">
<a href="/account-access/accounts/{encodeURIComponent(account.bank_id)}/{encodeURIComponent(account.account_id)}/owner" class="account-link">
<a href="/account-access/accounts/{encodeURIComponent(account.bank_id)}/{encodeURIComponent(account.account_id)}/public" class="account-link">
{account.account_id}
</a>
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
const res = await trackedFetch(`/proxy/obp/v6.0.0/banks/${encodeURIComponent(bankId)}/accounts`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch accounts`);
}
const data = await res.json();
accounts = data.accounts || [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
const msg = data.message || data.error || `HTTP ${res.status}`;
throw new Error(msg);
}
account = await res.json();
} catch (err) {
Expand All @@ -171,8 +172,7 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
}
throw new Error(data.message || data.error || `Failed to fetch users with access for view ${vid}`); }
const data = await res.json();
return { viewId: vid, users: data.users || [] };
})
Expand Down Expand Up @@ -391,7 +391,7 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch customer account links`);
}
const data = await res.json();
customerAccountLinks = data.links || [];
Expand Down Expand Up @@ -434,7 +434,7 @@

if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to create account attribute`);
}

const created = await res.json();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch counterparties`);
}
const data = await res.json();
counterparties = data.counterparties || [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch counterparty details`);
}
counterparty = await res.json();
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.message ?? `HTTP ${res.status}`);
throw new Error(data.message || data.error || `HTTP ${res.status} Failed to fetch transactions`);
}
const data = await res.json();
transactions = data.transactions || [];
Expand Down
26 changes: 26 additions & 0 deletions apps/api-manager/src/routes/(protected)/banks/create/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
let logo = $state("");
let website = $state("");
let isSubmitting = $state(false);
let submitError = $state<string | null>(null);

// Bank routings - dynamic list
let routings = $state<Array<{ scheme: string; address: string }>>([]);
Expand All @@ -35,6 +36,7 @@
}

isSubmitting = true;
submitError = null;

try {
const requestBody: Record<string, any> = {
Expand Down Expand Up @@ -90,6 +92,7 @@
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to create bank";
submitError = errorMessage;
toast.error("Error", errorMessage);
} finally {
isSubmitting = false;
Expand Down Expand Up @@ -290,6 +293,29 @@
</div>
</div>

<!-- Inline error banner -->
{#if submitError}
<div class="mt-6 flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4 dark:border-red-700 dark:bg-red-950">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-red-800 dark:text-red-300">Error</p>
<p class="mt-1 text-sm text-red-700 dark:text-red-400">{submitError}</p>
</div>
<button
type="button"
onclick={() => (submitError = null)}
class="text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300"
aria-label="Dismiss error"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}

<!-- Actions -->
<div class="mt-8 flex justify-end gap-3 border-t border-gray-200 pt-6 dark:border-gray-700">
<a
Expand Down
4 changes: 2 additions & 2 deletions apps/api-manager/src/routes/login/[provider]/+server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createLogger } from '@obp/shared/utils';
import { createLogger, isSafeRelativeRedirect } from '@obp/shared/utils';
const logger = createLogger('ProviderLogin');
import { generateState } from 'arctic'
import { oauth2ProviderFactory } from '$lib/oauth/providerFactory'
Expand Down Expand Up @@ -57,7 +57,7 @@ export function GET(event: RequestEvent) {

// Store redirect_to in a separate cookie so we can redirect after login
const redirectTo = event.url.searchParams.get('redirect_to');
if (redirectTo && redirectTo.startsWith('/')) {
if (isSafeRelativeRedirect(redirectTo)) {
event.cookies.set('obp_redirect_to', redirectTo, {
httpOnly: true,
maxAge: 60 * 10,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createLogger } from "@obp/shared/utils";
import { createLogger, isSafeRelativeRedirect } from "@obp/shared/utils";
const logger = createLogger("ProviderLoginCallback");
import { oauth2ProviderFactory } from "$lib/oauth/providerFactory";
import type { OAuth2Tokens } from "arctic";
Expand Down Expand Up @@ -244,8 +244,8 @@ export async function GET(event: RequestEvent): Promise<Response> {
// Redirect to the originally requested URL if available
let redirectTo = event.cookies.get("obp_redirect_to") || "/";
event.cookies.delete("obp_redirect_to", { path: "/" });
// Validate: must start with / to prevent open redirect
if (!redirectTo.startsWith("/")) {
// Validate: must be a same-origin relative path to prevent open redirect
if (!isSafeRelativeRedirect(redirectTo)) {
redirectTo = "/";
}

Expand Down
6 changes: 3 additions & 3 deletions apps/api-manager/src/routes/login/obp/callback/+server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createLogger } from "@obp/shared/utils";
import { createLogger, isSafeRelativeRedirect } from "@obp/shared/utils";
const logger = createLogger("OBPLoginCallback");
import { oauth2ProviderFactory } from "$lib/oauth/providerFactory";
import type { OAuth2Tokens } from "arctic";
Expand Down Expand Up @@ -238,8 +238,8 @@ export async function GET(event: RequestEvent): Promise<Response> {
// Redirect to the originally requested URL if available
let redirectTo = event.cookies.get("obp_redirect_to") || "/";
event.cookies.delete("obp_redirect_to", { path: "/" });
// Validate: must start with / to prevent open redirect
if (!redirectTo.startsWith("/")) {
// Validate: must be a same-origin relative path to prevent open redirect
if (!isSafeRelativeRedirect(redirectTo)) {
redirectTo = "/";
}

Expand Down
Loading
Loading