From 7ac48545f132690bf2f1ede1ce48171835b47241 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Fri, 31 Oct 2025 13:31:13 +0000 Subject: [PATCH 1/3] feat: add per-request authentication support with OAuth2 and API key --- src/http.ts | 17 ++++++ src/index.ts | 76 +++++++++++++++++++++--- src/types/auth.ts | 17 ++++++ src/types/index.ts | 1 + test/auth.test.ts | 142 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 src/types/auth.ts create mode 100644 test/auth.test.ts diff --git a/src/http.ts b/src/http.ts index 59d9ca3..0ccea6f 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,4 +1,5 @@ import { OpenCloudError, RateLimitError, AuthError } from "./errors"; +import type { AuthConfig } from "./types"; export type Backoff = "exponential" | "fixed"; @@ -50,6 +51,8 @@ const parseErrorDetails = async ( */ export class HttpClient { private fetcher: typeof fetch; + /** Optional auth override for per-request authentication */ + public authOverride?: AuthConfig; /** * Creates a new HttpClient instance. @@ -110,6 +113,20 @@ export class HttpClient { ...init.headers, }); + if (this.authOverride) { + if (this.authOverride.kind === "apiKey") { + headers.set("x-api-key", this.authOverride.apiKey); + } else { + headers.set("authorization", `Bearer ${this.authOverride.accessToken}`); + } + } + + if (!headers.has("x-api-key") && !headers.has("authorization")) { + throw new AuthError( + "No authentication provided. Pass an apiKey in OpenCloudConfig or use withAuth() for per-request authentication.", + ); + } + for (let attempt = 0; attempt <= retry.attempts; attempt++) { const response = await this.fetcher(url, { ...init, headers }); diff --git a/src/index.ts b/src/index.ts index 342e0c8..cd57e51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,17 @@ import { HttpClient, HttpOptions } from "./http"; import { Groups } from "./resources/groups"; import { Users } from "./resources/users"; +import type { AuthConfig } from "./types/auth"; /** * Configuration options for initializing the OpenCloud SDK client. */ export interface OpenCloudConfig { - /** Roblox Open Cloud API key for authentication */ - apiKey: string; + /** + * Roblox Open Cloud API key for authentication. + * This is optional and if not provided, you must use withAuth() for per-request authentication. + */ + apiKey?: string; /** Custom user agent string for API requests (defaults to "@relatiohq/opencloud") */ userAgent?: string; /** Base URL for the Roblox API (defaults to "https://apis.roblox.com") */ @@ -24,26 +28,34 @@ export interface OpenCloudConfig { * * @example * ```typescript + * // Traditional usage with API key * const client = new OpenCloud({ * apiKey: 'your-api-key-here' * }); - * * const user = await client.users.get('123456789'); * console.log(user.displayName); + * + * // Multi-tenant usage with per-request OAuth2 + * const client = new OpenCloud(); // No default auth + * const userClient = client.withAuth({ + * kind: "oauth2", + * accessToken: "user-token" + * }); + * const groups = await userClient.groups.listMemberships("123456"); * ``` */ export class OpenCloud { - /** Access to Users API endpoints */ - public users: Users; - public groups: Groups; + public users!: Users; + public groups!: Groups; + private http!: HttpClient; /** * Creates a new OpenCloud SDK client instance. * * @param config - Configuration options for the client */ - constructor(config: OpenCloudConfig) { - const http = new HttpClient({ + constructor(config: OpenCloudConfig = {}) { + this.http = new HttpClient({ baseUrl: config.baseUrl ?? "https://apis.roblox.com", retry: config.retry ?? { attempts: 4, @@ -51,15 +63,61 @@ export class OpenCloud { baseMs: 250, }, headers: { - "x-api-key": config.apiKey, + ...(config.apiKey ? { "x-api-key": config.apiKey } : {}), "user-agent": config.userAgent ?? "@relatiohq/opencloud", }, fetchImpl: config.fetchImpl, }); + this.initializeResources(this.http); + } + + /** + * Initializes all resource endpoints with the provided HTTP client. + * @private + */ + private initializeResources(http: HttpClient): void { this.users = new Users(http); this.groups = new Groups(http); } + + /** + * Creates a scoped client instance with per-request authentication. + * This allows reusing a single client while providing different credentials for each request. + * + * @param auth - Authentication configuration to use for this scope + * @returns A new scoped OpenCloud instance with the provided auth + * + * @example + * ```typescript + * const client = new OpenCloud(); // No default auth + * + * // OAuth2 authentication + * const userClient = client.withAuth({ + * kind: "oauth2", + * accessToken: "user-token-here" + * }); + * const groups = await userClient.groups.listMemberships("123456"); + * + * // API key authentication + * const adminClient = client.withAuth({ + * kind: "apiKey", + * apiKey: "admin-key-here" + * }); + * const user = await adminClient.users.get("789"); + * ``` + */ + withAuth(auth: AuthConfig): OpenCloud { + const scoped = Object.create(this) as OpenCloud; + + const scopedHttp = Object.create(this.http) as HttpClient; + scopedHttp.authOverride = auth; + + scoped.http = scopedHttp; + scoped.initializeResources(scopedHttp); + + return scoped; + } } export * from "./types"; diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..b72c572 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,17 @@ +/** + * Authentication configuration for API requests. + * Supports both API key and OAuth2 bearer token authentication. + */ +export type AuthConfig = + | { + /** Authentication method using an API key */ + kind: "apiKey"; + /** The API key value */ + apiKey: string; + } + | { + /** Authentication method using OAuth2 */ + kind: "oauth2"; + /** The OAuth2 access token */ + accessToken: string; + }; diff --git a/src/types/index.ts b/src/types/index.ts index 2b6211e..404c2af 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +export * from "./auth"; export * from "./common"; export * from "./users"; export * from "./groups"; diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..48729a6 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { OpenCloud } from "../src"; +import { AuthError } from "../src/errors"; +import { makeFetchMock } from "./_utils"; + +describe("Per-request auth override", () => { + it("should allow creating a client without default auth", () => { + const client = new OpenCloud(); + expect(client).toBeDefined(); + }); + + it("should throw AuthError when no auth is provided", async () => { + const { fetchMock } = makeFetchMock([ + { status: 200, body: { displayName: "Test User" } }, + ]); + + const client = new OpenCloud({ fetchImpl: fetchMock }); + + await expect(client.users.get("123")).rejects.toThrow(AuthError); + await expect(client.users.get("123")).rejects.toThrow( + "No authentication provided", + ); + }); + + it("should use OAuth2 token from withAuth()", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: { displayName: "Test User" } }, + ]); + + const client = new OpenCloud({ fetchImpl: fetchMock }); + const scopedClient = client.withAuth({ + kind: "oauth2", + accessToken: "test-token-123", + }); + + await scopedClient.users.get("123456"); + + expect(calls.length).toBe(1); + const headers = calls[0]?.init?.headers as Headers; + expect(headers.get("authorization")).toBe("Bearer test-token-123"); + expect(headers.has("x-api-key")).toBe(false); + }); + + it("should use API key from withAuth()", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: { displayName: "Test User" } }, + ]); + + const client = new OpenCloud({ fetchImpl: fetchMock }); + const scopedClient = client.withAuth({ + kind: "apiKey", + apiKey: "test-api-key-456", + }); + + await scopedClient.users.get("123456"); + + expect(calls.length).toBe(1); + const headers = calls[0]?.init?.headers as Headers; + expect(headers.get("x-api-key")).toBe("test-api-key-456"); + expect(headers.has("authorization")).toBe(false); + }); + + it("should override default API key with OAuth2 token", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: { displayName: "Test User" } }, + ]); + + const client = new OpenCloud({ + apiKey: "default-key", + fetchImpl: fetchMock, + }); + const scopedClient = client.withAuth({ + kind: "oauth2", + accessToken: "override-token", + }); + + await scopedClient.users.get("123456"); + + expect(calls.length).toBe(1); + const headers = calls[0]?.init?.headers as Headers; + expect(headers.get("authorization")).toBe("Bearer override-token"); + // The original x-api-key should still be present from default config, + // but the authorization header takes precedence + expect(headers.get("x-api-key")).toBe("default-key"); + }); + + it("should allow multiple scoped clients from same base client", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: { displayName: "Test User" } }, + ]); + + const client = new OpenCloud({ fetchImpl: fetchMock }); + + const user1Client = client.withAuth({ + kind: "oauth2", + accessToken: "token-1", + }); + const user2Client = client.withAuth({ + kind: "oauth2", + accessToken: "token-2", + }); + + await user1Client.users.get("111"); + await user2Client.users.get("222"); + + expect(calls.length).toBe(2); + + const headers1 = calls[0]?.init?.headers as Headers; + expect(headers1.get("authorization")).toBe("Bearer token-1"); + + const headers2 = calls[1]?.init?.headers as Headers; + expect(headers2.get("authorization")).toBe("Bearer token-2"); + }); + + it("should work in multi-tenant server scenario", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: { groupMemberships: [] } }, + ]); + + // Simulate a multi-tenant server + const client = new OpenCloud({ fetchImpl: fetchMock }); + + const handleRequest = async (userToken: string) => { + const userClient = client.withAuth({ + kind: "oauth2", + accessToken: userToken, + }); + return await userClient.groups.listGroupMemberships("123456"); + }; + + await handleRequest("user-1-token"); + await handleRequest("user-2-token"); + + expect(calls.length).toBe(2); + + const headers1 = calls[0]?.init?.headers as Headers; + expect(headers1.get("authorization")).toBe("Bearer user-1-token"); + + const headers2 = calls[1]?.init?.headers as Headers; + expect(headers2.get("authorization")).toBe("Bearer user-2-token"); + }); +}); From 959b0fb2a0f252bf0bf8998d48fe0b4d01a5a3a3 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Fri, 31 Oct 2025 13:31:34 +0000 Subject: [PATCH 2/3] test: add headers to HttpClient tests --- test/http.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/http.test.ts b/test/http.test.ts index e6c4f00..7368893 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -8,7 +8,11 @@ const baseUrl = "https://apis.roblox.com"; describe("HttpClient", () => { it("returns parsed JSON on 200", async () => { const { fetchMock } = makeFetchMock([{ status: 200, body: { ok: true } }]); - const http = new HttpClient({ baseUrl, fetchImpl: fetchMock }); + const http = new HttpClient({ + baseUrl, + fetchImpl: fetchMock, + headers: { "x-api-key": "test-key" }, + }); const response = await http.request<{ ok: boolean }>("/cloud/v2/ping"); expect(response.ok).toBe(true); @@ -52,6 +56,7 @@ describe("HttpClient", () => { const http = new HttpClient({ baseUrl, fetchImpl: fetchMock, + headers: { "x-api-key": "test-key" }, retry: { attempts: 3, backoff: "fixed", baseMs: 0 }, // make retry instant for test }); @@ -68,6 +73,7 @@ describe("HttpClient", () => { const http = new HttpClient({ baseUrl, fetchImpl: fetchMock, + headers: { "x-api-key": "test-key" }, retry: { attempts: 1, backoff: "fixed", baseMs: 0 }, }); From ffa8148c18648db67cd99a527b7706cdde0486b8 Mon Sep 17 00:00:00 2001 From: marinofranz Date: Fri, 31 Oct 2025 13:31:44 +0000 Subject: [PATCH 3/3] docs: update authentication guide with OAuth2 and per-request authentication examples --- README.md | 22 ++++++- src/docs/guide/authentication.md | 109 +++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e6b629e..45252e6 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,36 @@ pnpm add @relatiohq/opencloud yarn add @relatiohq/opencloud ``` -## Usage +## Quick Start ```typescript import { OpenCloud } from "@relatiohq/opencloud"; +// With API key authentication const client = new OpenCloud({ apiKey: "your-api-key", }); -// Use the client const user = await client.users.get("123456789"); ``` +### Per-Request Authentication (OAuth2) + +Perfect for multi-tenant applications: + +```typescript +// Create client without default auth +const client = new OpenCloud(); + +// Use different credentials per request +const userClient = client.withAuth({ + kind: "oauth2", + accessToken: "user-access-token", +}); + +const groups = await userClient.groups.listGroupMemberships("123456"); +``` + ## Documentation Full API documentation is available at [https://opencloud.relatio.cc](https://opencloud.relatio.cc) @@ -42,6 +59,7 @@ Full API documentation is available at [https://opencloud.relatio.cc](https://op - Tree-shakeable exports - Lightweight with zero dependencies - Automatic retry with exponential backoff +- Flexible authentication ## Why Use This SDK? diff --git a/src/docs/guide/authentication.md b/src/docs/guide/authentication.md index 410f466..f873249 100644 --- a/src/docs/guide/authentication.md +++ b/src/docs/guide/authentication.md @@ -1,8 +1,12 @@ # Authentication -The OpenCloud SDK requires a Roblox Open Cloud API key for authentication. +The OpenCloud SDK supports two authentication methods: API keys and OAuth2 access tokens. -## Getting an API Key +## API Key Authentication + +API keys are best for server-side applications that need to access Roblox resources on behalf of your application. + +### Getting an API Key 1. Go to the [Roblox Creator Dashboard](https://create.roblox.com/dashboard/credentials) 2. Click **Create API Key** @@ -13,7 +17,7 @@ The OpenCloud SDK requires a Roblox Open Cloud API key for authentication. Keep your API key secret! Never commit it to version control or share it publicly. ::: -## Using the API Key +### Using the API Key Pass your API key when creating the OpenCloud client: @@ -25,6 +29,77 @@ const client = new OpenCloud({ }); ``` +## OAuth2 Authentication + +OAuth2 is ideal for applications that need to access Roblox resources on behalf of individual users. This is commonly used in multi-tenant scenarios where each user has their own access token. + +### Per-Request Authentication + +You can create a client without default credentials and provide authentication per-request using the `withAuth()` method: + +```typescript +import { OpenCloud } from "@relatiohq/opencloud"; + +// Create a client without default authentication +const client = new OpenCloud(); + +// Use OAuth2 for a specific request +const userClient = client.withAuth({ + kind: "oauth2", + accessToken: "user-access-token" +}); + +const groups = await userClient.groups.listGroupMemberships("123456"); +``` + +### Multi-Tenant Server Example + +This pattern is perfect for backend services that handle requests from multiple users: + +```typescript +import { OpenCloud } from "@relatiohq/opencloud"; +import express from "express"; + +const app = express(); +const client = new OpenCloud(); // Shared client, no default auth + +app.get("/api/my-groups", async (req, res) => { + // Get user's access token from request + const accessToken = req.headers.authorization?.split(" ")[1]; + + // Create scoped client for this user + const userClient = client.withAuth({ + kind: "oauth2", + accessToken: accessToken! + }); + + // Make requests on behalf of the user + const memberships = await userClient.groups.listGroupMemberships(req.query.userId); + res.json(memberships); +}); +``` + +### Overriding Default Authentication + +You can also create a client with default authentication and override it per-request: + +```typescript +// Client with default API key +const client = new OpenCloud({ + apiKey: "default-api-key" +}); + +// Most requests use the default API key +const group = await client.groups.get("123"); + +// But you can override with OAuth2 for specific requests +const userClient = client.withAuth({ + kind: "oauth2", + accessToken: "user-token" +}); +const userGroups = await userClient.groups.listGroupMemberships("456"); +``` + ## Environment Variables It's recommended to store your API key in environment variables: @@ -54,6 +129,32 @@ const client = new OpenCloud({ }); ``` +## Authentication Types + +The SDK supports two authentication types through the `AuthConfig` type: + +### API Key + +```typescript +const auth = { + kind: "apiKey", + apiKey: "your-api-key" +}; + +const client = new OpenCloud().withAuth(auth); +``` + +### OAuth2 + +```typescript +const auth = { + kind: "oauth2", + accessToken: "user-access-token" +}; + +const client = new OpenCloud().withAuth(auth); +``` + ## Permissions When creating your API key, ensure it has the appropriate permissions for the resources you need to access: @@ -63,4 +164,4 @@ When creating your API key, ensure it has the appropriate permissions for the re - **Assets** - Upload and manage assets - And more... -Refer to the [Roblox Open Cloud documentation](https://create.roblox.com/docs/cloud/open-cloud) for the full list of available permissions. +Refer to the [Roblox Open Cloud documentation](https://create.roblox.com/docs/cloud/open-cloud) for the full list of available permissions. \ No newline at end of file