diff --git a/.changeset/custom-code-exchange.md b/.changeset/custom-code-exchange.md new file mode 100644 index 0000000..8c6a08a --- /dev/null +++ b/.changeset/custom-code-exchange.md @@ -0,0 +1,26 @@ +--- +"@nylas/connect": major +--- + +Add custom code exchange functionality for enhanced security. + +### Usage + +```typescript +// Handle code exchange on your backend +const nylasConnect = new NylasConnect({ + clientId: 'your-client-id', + redirectUri: 'http://localhost:3000/callback', + codeExchange: async (params) => { + const response = await fetch('/api/auth/exchange', { + method: 'POST', + body: JSON.stringify(params), + }); + return await response.json(); + } +}); + +// Use normally - custom exchange is called automatically +const result = await nylasConnect.connect({ method: 'popup' }); +``` + diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index e2ef065..f42ba76 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: test: name: Test Suite - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c691a3..21ab177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: name: Release - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest permissions: contents: write # to create release commits and tags pull-requests: write # to create release PRs diff --git a/packages/nylas-connect/README.md b/packages/nylas-connect/README.md index f7c7f2a..39f9802 100644 --- a/packages/nylas-connect/README.md +++ b/packages/nylas-connect/README.md @@ -119,6 +119,81 @@ try { | `apiUrl` | `string` | `https://api.us.nylas.com` | API base URL | | `persistTokens` | `boolean` | `true` | Store tokens in localStorage | | `debug` | `boolean` | `true` on localhost | Enable debug logging | +| `codeExchange` | (param: CodeExchangeParams) => Promise` | - | Custom code exchange method | + +## Custom Code Exchange + +For enhanced security, you can handle the OAuth code exchange on your backend instead of in the browser. This approach keeps your API keys secure and gives you full control over the token exchange process. + +### Backend Code Exchange + +```typescript +const nylasConnect = new NylasConnect({ + clientId: 'your-nylas-client-id', + redirectUri: 'http://localhost:3000/auth/callback', + codeExchange: async (params) => { + // Send the authorization code to your backend + const response = await fetch('/api/auth/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: params.code, + state: params.state, + clientId: params.clientId, + redirectUri: params.redirectUri, + scopes: params.scopes, + provider: params.provider, + }), + }); + + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`); + } + + const tokenData = await response.json(); + + // Return the expected ConnectResult format + return { + accessToken: tokenData.access_token, + idToken: tokenData.id_token, + grantId: tokenData.grant_id, + expiresAt: Date.now() + tokenData.expires_in * 1000, + scope: tokenData.scope, + grantInfo: tokenData.grant_info, + }; + } +}); + +// Use normally - the custom exchange will be called automatically +const result = await nylasConnect.connect({ method: 'popup' }); +``` + +### Backend Implementation Example + +```typescript +// Example backend endpoint (/api/auth/exchange) +export async function POST(request: Request) { + const { code, clientId, redirectUri } = await request.json(); + + // Exchange code for tokens using your API key + const response = await fetch('https://api.us.nylas.com/connect/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Bearer ${process.env.NYLAS_API_KEY}`, + }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + code, + grant_type: 'authorization_code', + }), + }); + + const tokenData = await response.json(); + return Response.json(tokenData); +} +``` ## API diff --git a/packages/nylas-connect/src/connect-client.test.ts b/packages/nylas-connect/src/connect-client.test.ts index 3335b6a..a70f2da 100644 --- a/packages/nylas-connect/src/connect-client.test.ts +++ b/packages/nylas-connect/src/connect-client.test.ts @@ -760,6 +760,434 @@ describe("NylasConnect (sessions, validation, and events)", () => { }); }); +describe("NylasConnect (custom code exchange)", () => { + const clientId = "client_123"; + const redirectUri = "https://app.example/callback"; + + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("uses custom code exchange method when provided in config", async () => { + const mockCustomExchange = vi.fn().mockResolvedValue({ + accessToken: "custom_access_token", + idToken: "custom_id_token", + grantId: "custom_grant_123", + expiresAt: Date.now() + 3600000, + scope: "email", + grantInfo: { + id: "user123", + email: "test@example.com", + name: "Test User", + provider: "google", + }, + }); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + // Mock successful token validation + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { grant_id: "custom_grant_123" } }), + }), + ); + + // Simulate popup callback + const spy = vi.fn(); + auth.onConnectStateChange(spy); + + // Simulate the callback flow + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + const result = await auth.handleRedirectCallback(callbackUrl); + + // Verify custom exchange was called with correct parameters + expect(mockCustomExchange).toHaveBeenCalledWith({ + code: "auth_code_123", + state: "stateXYZ", + codeVerifier: "verifier123", + scopes: [], + provider: undefined, + clientId, + redirectUri, + }); + + // Verify result + expect(result.accessToken).toBe("custom_access_token"); + expect(result.grantId).toBe("custom_grant_123"); + + // Verify events were emitted + const events = spy.mock.calls.map((call) => call[0]); + expect(events).toContain("CONNECT_SUCCESS"); + expect(events).toContain("SIGNED_IN"); + }); + + it("disables PKCE when custom code exchange is provided", async () => { + const mockCustomExchange = vi.fn().mockResolvedValue({ + accessToken: "access_token", + idToken: "id_token", + grantId: "grant_123", + expiresAt: Date.now() + 3600000, + scope: "email", + }); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + const url = await auth.connect({ method: "inline" }); + + // URL should not contain PKCE parameters when custom exchange is used + expect(url).not.toContain("code_challenge"); + expect(url).not.toContain("code_challenge_method"); + expect(url).toContain(`client_id=${encodeURIComponent(clientId)}`); + expect(url).toContain(`redirect_uri=${encodeURIComponent(redirectUri)}`); + }); + + it("includes PKCE when no custom code exchange is provided", async () => { + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + }); + + const url = await auth.connect({ method: "inline" }); + + // URL should contain PKCE parameters when using built-in exchange + expect(url).toContain("code_challenge=challengeABC"); + expect(url).toContain("code_challenge_method=S256"); + }); + + it("validates custom code exchange result", async () => { + const mockCustomExchange = vi.fn().mockResolvedValue({ + // Missing required fields + idToken: "id_token", + expiresAt: Date.now() + 3600000, + scope: "email", + }); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + const spy = vi.fn(); + auth.onConnectStateChange(spy); + + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + + await expect(auth.handleRedirectCallback(callbackUrl)).rejects.toThrow( + "Custom code exchange failed", + ); + + // Verify error event was emitted + const events = spy.mock.calls.map((call) => call[0]); + expect(events).toContain("CONNECT_ERROR"); + }); + + it("handles custom code exchange errors gracefully", async () => { + const mockCustomExchange = vi + .fn() + .mockRejectedValue(new Error("Backend exchange failed")); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + const spy = vi.fn(); + auth.onConnectStateChange(spy); + + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + + await expect(auth.handleRedirectCallback(callbackUrl)).rejects.toThrow( + "Custom code exchange failed", + ); + + // Verify error event was emitted + const events = spy.mock.calls.map((call) => call[0]); + expect(events).toContain("CONNECT_ERROR"); + }); + + it("passes correct parameters to custom code exchange with provider and scopes", async () => { + const mockCustomExchange = vi.fn().mockResolvedValue({ + accessToken: "access_token", + idToken: "id_token", + grantId: "grant_123", + expiresAt: Date.now() + 3600000, + scope: "email calendar", + grantInfo: { + id: "user123", + email: "test@example.com", + name: "Test User", + provider: "google", + }, + }); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + defaultScopes: ["email", "calendar"], + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: ["email", "calendar"], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + // Mock successful token validation + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: { grant_id: "grant_123" } }), + }), + ); + + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + await auth.handleRedirectCallback(callbackUrl); + + expect(mockCustomExchange).toHaveBeenCalledWith({ + code: "auth_code_123", + state: "stateXYZ", + codeVerifier: "verifier123", + scopes: ["email", "calendar"], + provider: undefined, + clientId, + redirectUri, + }); + }); + + it("stores tokens from custom code exchange correctly", async () => { + const customResult = { + accessToken: "custom_access_token", + idToken: "custom_id_token", + grantId: "custom_grant_123", + expiresAt: Date.now() + 3600000, + scope: "email", + grantInfo: { + id: "user123", + email: "test@example.com", + name: "Test User", + provider: "google", + }, + }; + + const mockCustomExchange = vi.fn().mockResolvedValue(customResult); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + await auth.handleRedirectCallback(callbackUrl); + + // Verify tokens were stored + const storedSession = await auth.getSession("custom_grant_123"); + expect(storedSession).toEqual(customResult); + + // Verify default session was also set + const defaultSession = await auth.getSession(); + expect(defaultSession).toEqual(customResult); + }); + + it("logs custom code exchange usage", async () => { + const mockCustomExchange = vi.fn().mockResolvedValue({ + accessToken: "access_token", + idToken: "id_token", + grantId: "grant_123", + expiresAt: Date.now() + 3600000, + scope: "email", + }); + + // Enable debug logging + logger.setLevel(LogLevel.DEBUG); + const logSpy = vi.spyOn(logger, "info"); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + codeExchange: mockCustomExchange, + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + // Check authentication start logging + await auth.connect({ method: "inline" }); + + expect(logSpy).toHaveBeenCalledWith( + "Starting authentication", + expect.objectContaining({ + customCodeExchange: true, + }), + ); + + // Check custom exchange logging + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + await auth.handleRedirectCallback(callbackUrl); + + expect(logSpy).toHaveBeenCalledWith("Using custom code exchange method"); + expect(logSpy).toHaveBeenCalledWith( + "Custom code exchange successful", + expect.objectContaining({ + grantId: "grant_123", + scope: "email", + }), + ); + }); + + it("falls back to built-in exchange when no custom method provided", async () => { + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + }); + + // Store auth state manually for the test + const authState = { + codeVerifier: "verifier123", + state: "stateXYZ", + scopes: [], + timestamp: Date.now(), + }; + localStorage.setItem( + `@nylas/connect:nylas_auth_state_${clientId}`, + JSON.stringify(authState), + ); + + // Mock built-in token exchange + const mockTokenResponse = { + access_token: "builtin_access_token", + id_token: createValidIdToken(), + grant_id: "builtin_grant_123", + expires_in: 3600, + scope: "email", + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockTokenResponse, + }), + ); + + const callbackUrl = `${redirectUri}?code=auth_code_123&state=stateXYZ`; + const result = await auth.handleRedirectCallback(callbackUrl); + + // Verify built-in exchange was used + expect(result.accessToken).toBe("builtin_access_token"); + expect(result.grantId).toBe("builtin_grant_123"); + expect(fetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }), + ); + }); + + function createValidIdToken(): string { + const header = { alg: "RS256", typ: "JWT" }; + const payload = { + sub: "user123", + email: "test@example.com", + name: "Test User", + provider: "google", + email_verified: true, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + return [base64url(header), base64url(payload), "signature"].join("."); + } +}); + describe("NylasConnect (API URL normalization)", () => { const clientId = "client_123"; const redirectUri = "https://app.example/callback"; diff --git a/packages/nylas-connect/src/connect-client.ts b/packages/nylas-connect/src/connect-client.ts index abc474a..1c44241 100644 --- a/packages/nylas-connect/src/connect-client.ts +++ b/packages/nylas-connect/src/connect-client.ts @@ -14,6 +14,7 @@ import type { SessionData, LogLevel, Provider, + CodeExchangeParams, } from "./types"; import { ConnectStatus } from "./types"; import { generatePKCE, generateState } from "./crypto/pkce"; @@ -40,8 +41,8 @@ import { * Modern Nylas authentication client */ export class NylasConnect { - private config: Required> & - Pick; + private config: Required> & + Pick; private storage: TokenStorage; private connectStateCallbacks: Set = new Set(); @@ -74,6 +75,7 @@ export class NylasConnect { persistTokens: resolvedConfig.persistTokens!, autoHandleCallback: resolvedConfig.autoHandleCallback!, logLevel: resolvedConfig.logLevel, + codeExchange: resolvedConfig.codeExchange, }; // Configure logger based on config @@ -126,6 +128,7 @@ export class NylasConnect { persistTokens: config.persistTokens ?? true, autoHandleCallback: config.autoHandleCallback ?? true, logLevel: config.logLevel, + codeExchange: config.codeExchange, }; } @@ -302,6 +305,7 @@ export class NylasConnect { method: options.method, scopes, provider: options.provider, + customCodeExchange: !!this.config.codeExchange, }); // Store auth state for later retrieval using global key @@ -325,7 +329,8 @@ export class NylasConnect { clientId: this.config.clientId, redirectUri: this.config.redirectUri, scopes, - codeChallenge, + // Only include PKCE when using built-in token exchange + codeChallenge: this.config.codeExchange ? undefined : codeChallenge, state, provider: options.provider, loginHint: options.loginHint, @@ -354,7 +359,13 @@ export class NylasConnect { reason: "completed", }); - const result = await this.exchangeCodeForTokens(authCode, codeVerifier); + const result = await this.performCodeExchange( + authCode, + codeVerifier, + scopes, + options.provider, + state, + ); // Emit CONNECT_SUCCESS before SIGNED_IN this.triggerConnectStateChange("CONNECT_SUCCESS", result, { @@ -516,9 +527,12 @@ export class NylasConnect { } // Exchange code for tokens BEFORE cleaning URL - const result = await this.exchangeCodeForTokens( + const result = await this.performCodeExchange( code, storedState.codeVerifier, + storedState.scopes, + undefined, // Provider info not available in stored state + state, ); // Emit CONNECT_SUCCESS event @@ -881,6 +895,83 @@ export class NylasConnect { return this.config.defaultScopes || []; } + /** + * Perform code exchange using custom method or built-in method + */ + private async performCodeExchange( + code: string, + codeVerifier: string, + scopes: string[], + provider?: Provider, + state?: string, + ): Promise { + if (this.config.codeExchange) { + // Use custom code exchange method + logger.info("Using custom code exchange method"); + + const params: CodeExchangeParams = { + code, + state: state || "", + codeVerifier, + scopes, + provider, + clientId: this.config.clientId, + redirectUri: this.config.redirectUri, + }; + + try { + const result = await this.config.codeExchange(params); + + // Validate the result from custom exchange + if (!result.accessToken || !result.grantId) { + throw new TokenError( + "Invalid result from custom code exchange", + "Custom code exchange method must return accessToken and grantId", + ); + } + + // Store tokens + const key = this.tokenKey(result.grantId); + await this.storage.set(key, JSON.stringify(result)); + + // Also store as default if no other default exists + const defaultToken = await this.storage.get(this.tokenKey()); + if (!defaultToken) { + await this.storage.set(this.tokenKey(), JSON.stringify(result)); + } + + logger.info("Custom code exchange successful", { + grantId: result.grantId, + scope: result.scope, + }); + + return result; + } catch (error) { + logger.error("Custom code exchange failed", error); + + const tokenError = + error instanceof Error + ? new TokenError( + "Custom code exchange failed", + error.message, + error, + ) + : new TokenError("Custom code exchange failed", "Unknown error"); + + // Emit CONNECT_ERROR event for custom exchange failures + this.triggerConnectStateChange("CONNECT_ERROR", null, { + error: tokenError, + step: "custom_code_exchange", + }); + + throw tokenError; + } + } else { + // Use built-in token exchange + return await this.exchangeCodeForTokens(code, codeVerifier); + } + } + /** * Exchange authorization code for tokens */ diff --git a/packages/nylas-connect/src/types.ts b/packages/nylas-connect/src/types.ts index 432d269..5c756e0 100644 --- a/packages/nylas-connect/src/types.ts +++ b/packages/nylas-connect/src/types.ts @@ -71,6 +71,34 @@ export type ProviderScopes = { */ export type Environment = "development" | "staging" | "production"; +/** + * Parameters passed to custom code exchange method + */ +export interface CodeExchangeParams { + /** Authorization code from OAuth callback */ + code: string; + /** State parameter from OAuth callback */ + state: string; + /** PKCE code verifier (if PKCE was used) */ + codeVerifier?: string; + /** Scopes that were requested */ + scopes: string[]; + /** Provider used for authentication */ + provider?: Provider; + /** Client ID */ + clientId: string; + /** Redirect URI */ + redirectUri: string; +} + +/** + * Custom code exchange method signature + * Should return a ConnectResult with tokens and grant info + */ +export type CodeExchangeMethod = ( + params: CodeExchangeParams, +) => Promise; + /** * Core configuration for NylasConnect */ @@ -93,6 +121,8 @@ export interface ConnectConfig { autoHandleCallback?: boolean; /** Set specific log level for the logger (overrides debug flag) */ logLevel?: LogLevel | "off"; + /** Custom code exchange method - if provided, will be used instead of built-in token exchange */ + codeExchange?: CodeExchangeMethod; } /**