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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ packages/react-components/FIGMA_MAPPING_RULES.md
packages/react-components/src/shadcn/components/ui/input+desc.tsx
.vscode/settings.json
packages/react-components/src/aidbox-ui.code-workspace
CLAUDE.local.md
2 changes: 2 additions & 0 deletions packages/aidbox-client/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
/tmp
/.codegen-cache
/docs
docker-compose.override.yml
CLAUDE.local.md
99 changes: 81 additions & 18 deletions packages/aidbox-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,12 +253,74 @@ Both methods can throw the `RequestError` class if the error happened before the

## Authentication Providers

Authentication is managed via the `AuthProvider` interface.
Authentication is managed via the `AuthProvider` interface. The client ships with three built-in providers:

Currently, the client only provides a `BrowserAuthProvider` class.
It is suitable for usage in browsers, but other environments may require a different method.
| Provider | Environment | Auth Method |
|----------|-------------|-------------|
| `BrowserAuthProvider` | Browser | Cookie-based sessions |
| `BasicAuthProvider` | Any | HTTP Basic Auth |
| `SmartBackendServicesAuthProvider` | Server-side | OAuth 2.0 client_credentials with JWT bearer |

Thus, an application can describe its own Auth Provider by implementing a class that implements `AuthProvider`:
### BrowserAuthProvider

For browser applications. Uses cookie-based sessions and redirects to the login page on 401.

```typescript
import { AidboxClient, BrowserAuthProvider } from "@health-samurai/aidbox-client";

const baseUrl = "https://fhir-server.address";
const client = new AidboxClient(baseUrl, new BrowserAuthProvider(baseUrl));
```

### BasicAuthProvider

For server-side applications using HTTP Basic Auth.

```typescript
import { AidboxClient, BasicAuthProvider } from "@health-samurai/aidbox-client";

const baseUrl = "https://fhir-server.address";
const client = new AidboxClient(
baseUrl,
new BasicAuthProvider(baseUrl, "username", "password"),
);
```

### SmartBackendServicesAuthProvider

For server-to-server authentication using [SMART Backend Services](https://www.hl7.org/fhir/smart-app-launch/backend-services.html) (OAuth 2.0 client_credentials grant with JWT bearer assertion).

Features:
- Token caching with proactive refresh before expiry
- Thundering herd prevention — concurrent requests share a single token fetch
- Automatic retry on 401 with fresh token
- OAuth2 discovery from `.well-known/smart-configuration`

```typescript
import { AidboxClient, SmartBackendServicesAuthProvider } from "@health-samurai/aidbox-client";

// Generate or import your private key using Web Crypto API
const privateKey = await crypto.subtle.generateKey(
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-384" },
true,
["sign", "verify"]
).then(kp => kp.privateKey);

const auth = new SmartBackendServicesAuthProvider({
baseUrl: "https://fhir-server.address",
clientId: "my-service",
privateKey: privateKey, // CryptoKey from Web Crypto API
keyId: "key-001", // Must match kid in JWKS
scope: "system/*.read",
// tokenExpirationBuffer: 30, // Optional: seconds before expiry to refresh (default: 30)
});

const client = new AidboxClient("https://fhir-server.address", auth);
```

### Custom Auth Provider

For other authentication methods, implement the `AuthProvider` interface:

```typescript
import type { AuthProvider } from "@health-samurai/aidbox-client";
Expand All @@ -267,30 +329,31 @@ export class CustomAuthProvider implements AuthProvider {
public baseUrl: string;

constructor(baseUrl: string) {
this.baseUrl = baseUrl;
this.baseUrl = baseUrl;
}

public async establishSession() {
/* code to establish a session */
/* code to establish a session */
}

public async revokeSession() {
/* code to revoke the session */
/* code to revoke the session */
}

/**
* A wrapper around the `fetch` function, that does all the
* necessary preparations and argument patching required for the
* request to go through.
*
* Optionally, security checks can be implemented, like verifying
* that the request indeed goes to the `baseUrl`, and not
* somewhere else.
*/
public async fetch(
input: RequestInfo | URL,
init?: RequestInit,
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
/**
* A wrapper around the `fetch` function, that does all the
* necessary preparations and argument patching required for the
* request to go through.
*
* Optionally, security checks can be implemented, like verifying
* that the request indeed goes to the `baseUrl`, and not
* somewhere else.
*/
/* ... */
}
}
```
1 change: 1 addition & 0 deletions packages/aidbox-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"dependencies": {
"@types/json-patch": "^0.0.33",
"oauth4webapi": "^3.8.3",
"yaml": "^2.8.1"
},
"publishConfig": {
Expand Down
62 changes: 17 additions & 45 deletions packages/aidbox-client/src/auth-providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthProvider } from "./types";
import { mergeHeaders, validateBaseUrl } from "./utils";

export class BrowserAuthProvider implements AuthProvider {
/** @ignore */
Expand Down Expand Up @@ -55,19 +56,12 @@ export class BrowserAuthProvider implements AuthProvider {
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
var url: string;
validateBaseUrl(input, this.baseUrl);

if (input instanceof Request) url = input.url;
else url = input.toString();
const requestInit = init ?? {};
requestInit.credentials = "include";

if (!url.startsWith(this.baseUrl))
throw Error("url of the request must start with baseUrl");

const i = init ?? {};

i.credentials = "include";

const response = await fetch(input, i);
const response = await fetch(input, requestInit);

if (response.status === 401) {
await this.establishSession();
Expand Down Expand Up @@ -104,39 +98,17 @@ export class BasicAuthProvider implements AuthProvider {
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
let url: string;

if (input instanceof Request) url = input.url;
else url = input.toString();

if (!url.startsWith(this.baseUrl))
throw Error("url of the request must start with baseUrl");

const i = init ?? {};

// Merge headers from Request object (if any), init.headers, and Authorization
const mergedHeaders = new Headers();

// First, copy headers from Request object if input is a Request
if (input instanceof Request) {
input.headers.forEach((value, key) => {
mergedHeaders.set(key, value);
});
}

// Then, copy headers from init (overrides Request headers)
if (i.headers) {
const initHeaders = new Headers(i.headers);
initHeaders.forEach((value, key) => {
mergedHeaders.set(key, value);
});
}

// Finally, set Authorization header
mergedHeaders.set("Authorization", this.#authHeader);

i.headers = mergedHeaders;

return fetch(input, i);
validateBaseUrl(input, this.baseUrl);

const requestInit = init ?? {};
const baseHeaders = input instanceof Request ? input.headers : undefined;
const initHeaders = requestInit.headers
? new Headers(requestInit.headers)
: undefined;
const headers = mergeHeaders(baseHeaders, initHeaders);
headers.set("Authorization", this.#authHeader);
requestInit.headers = headers;

return fetch(input, requestInit);
}
}
1 change: 1 addition & 0 deletions packages/aidbox-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export type * from "./fhir-types/hl7-fhir-r4-core";
export * from "./fhir-types/hl7-fhir-r4-core";
export type * from "./result";
export * from "./result";
export * from "./smart-backend-services";
export type * from "./types";
export * from "./types";
Loading