From 357456ca13ecb5c3940e25a76d69eb010c9e0bf7 Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Wed, 24 Sep 2025 19:02:24 +0200 Subject: [PATCH 1/9] allow custom code exchange --- .changeset/custom-code-exchange.md | 34 ++ packages/nylas-connect/README.md | 98 ++++ .../nylas-connect/src/connect-client.test.ts | 428 ++++++++++++++++++ packages/nylas-connect/src/connect-client.ts | 101 ++++- packages/nylas-connect/src/types.ts | 30 ++ 5 files changed, 686 insertions(+), 5 deletions(-) create mode 100644 .changeset/custom-code-exchange.md diff --git a/.changeset/custom-code-exchange.md b/.changeset/custom-code-exchange.md new file mode 100644 index 0000000..02e8513 --- /dev/null +++ b/.changeset/custom-code-exchange.md @@ -0,0 +1,34 @@ +--- +"@nylas/connect": minor +--- + +Add custom code exchange functionality for enhanced security + +This release adds support for custom OAuth code exchange methods, allowing developers to handle token exchange on their backend for enhanced security. Key features include: + +- **Custom Code Exchange**: Inject a custom function to handle authorization code exchange on your backend +- **Automatic PKCE Management**: PKCE is automatically disabled when using custom exchange (not needed for confidential clients) +- **Type Safety**: Full TypeScript support with well-defined interfaces for custom exchange parameters +- **Backward Compatibility**: No breaking changes - existing flows continue to work unchanged +- **Enhanced Security**: Keep API keys secure on the backend while using convenient popup authentication + +### Usage + +```typescript +const nylasConnect = new NylasConnect({ + clientId: 'your-client-id', + redirectUri: 'http://localhost:3000/callback', + codeExchange: async (params) => { + // Handle code exchange on your backend + 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/packages/nylas-connect/README.md b/packages/nylas-connect/README.md index f7c7f2a..11c8381 100644 --- a/packages/nylas-connect/README.md +++ b/packages/nylas-connect/README.md @@ -119,6 +119,104 @@ 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` | `function` | - | 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); +} +``` + +### Key Benefits + +- **🔐 Enhanced Security**: API keys never exposed to the browser +- **🎛️ Full Control**: Handle token validation, user creation, etc. +- **📊 Audit Trail**: Log all authentication events on your backend +- **🔄 Automatic PKCE**: When using custom exchange, PKCE is automatically disabled (not needed for confidential clients) + +### CodeExchangeParams + +The `codeExchange` function receives these parameters: + +```typescript +interface CodeExchangeParams { + code: string; // Authorization code from OAuth callback + state: string; // State parameter for CSRF protection + codeVerifier?: string; // PKCE code verifier (optional) + scopes: string[]; // Requested scopes + provider?: string; // OAuth provider (google, microsoft, etc.) + clientId: string; // Your Nylas Client ID + redirectUri: string; // OAuth redirect URI +} +``` ## API diff --git a/packages/nylas-connect/src/connect-client.test.ts b/packages/nylas-connect/src/connect-client.test.ts index 38fe906..1aab3b7 100644 --- a/packages/nylas-connect/src/connect-client.test.ts +++ b/packages/nylas-connect/src/connect-client.test.ts @@ -759,3 +759,431 @@ 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/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("."); + } +}); diff --git a/packages/nylas-connect/src/connect-client.ts b/packages/nylas-connect/src/connect-client.ts index 01d0476..21f3888 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 @@ -110,6 +112,7 @@ export class NylasConnect { persistTokens: config.persistTokens ?? true, autoHandleCallback: config.autoHandleCallback ?? true, logLevel: config.logLevel, + codeExchange: config.codeExchange, }; } @@ -286,6 +289,7 @@ export class NylasConnect { method: options.method, scopes, provider: options.provider, + customCodeExchange: !!this.config.codeExchange, }); // Store auth state for later retrieval using global key @@ -309,7 +313,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, @@ -338,7 +343,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, { @@ -500,9 +511,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 @@ -865,6 +879,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; } /** From ae1d15a8945be3e8ed9119867e9faf2e1b45842b Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Tue, 7 Oct 2025 17:53:40 +0200 Subject: [PATCH 2/9] sort out readme and details --- .changeset/custom-code-exchange.md | 10 +--------- packages/nylas-connect/README.md | 9 +-------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/.changeset/custom-code-exchange.md b/.changeset/custom-code-exchange.md index 02e8513..6c088ba 100644 --- a/.changeset/custom-code-exchange.md +++ b/.changeset/custom-code-exchange.md @@ -2,15 +2,7 @@ "@nylas/connect": minor --- -Add custom code exchange functionality for enhanced security - -This release adds support for custom OAuth code exchange methods, allowing developers to handle token exchange on their backend for enhanced security. Key features include: - -- **Custom Code Exchange**: Inject a custom function to handle authorization code exchange on your backend -- **Automatic PKCE Management**: PKCE is automatically disabled when using custom exchange (not needed for confidential clients) -- **Type Safety**: Full TypeScript support with well-defined interfaces for custom exchange parameters -- **Backward Compatibility**: No breaking changes - existing flows continue to work unchanged -- **Enhanced Security**: Keep API keys secure on the backend while using convenient popup authentication +Add custom code exchange functionality for enhanced security. ### Usage diff --git a/packages/nylas-connect/README.md b/packages/nylas-connect/README.md index 11c8381..c06fdc2 100644 --- a/packages/nylas-connect/README.md +++ b/packages/nylas-connect/README.md @@ -119,7 +119,7 @@ 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` | `function` | - | Custom code exchange method | +| `codeExchange` | (param: CodeExchangeParams) => Promise` | - | Custom code exchange method | ## Custom Code Exchange @@ -195,13 +195,6 @@ export async function POST(request: Request) { } ``` -### Key Benefits - -- **🔐 Enhanced Security**: API keys never exposed to the browser -- **🎛️ Full Control**: Handle token validation, user creation, etc. -- **📊 Audit Trail**: Log all authentication events on your backend -- **🔄 Automatic PKCE**: When using custom exchange, PKCE is automatically disabled (not needed for confidential clients) - ### CodeExchangeParams The `codeExchange` function receives these parameters: From 71a80164d82ca77a30f12689bfa5e120fc460a4b Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Tue, 7 Oct 2025 17:54:16 +0200 Subject: [PATCH 3/9] bump to major version --- .changeset/custom-code-exchange.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/custom-code-exchange.md b/.changeset/custom-code-exchange.md index 6c088ba..13f6622 100644 --- a/.changeset/custom-code-exchange.md +++ b/.changeset/custom-code-exchange.md @@ -1,5 +1,5 @@ --- -"@nylas/connect": minor +"@nylas/connect": major --- Add custom code exchange functionality for enhanced security. From 6781560abfc4b3130f7e74c978e137908c56dcd2 Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Tue, 7 Oct 2025 17:54:59 +0200 Subject: [PATCH 4/9] thin readme --- packages/nylas-connect/README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/nylas-connect/README.md b/packages/nylas-connect/README.md index c06fdc2..39f9802 100644 --- a/packages/nylas-connect/README.md +++ b/packages/nylas-connect/README.md @@ -195,22 +195,6 @@ export async function POST(request: Request) { } ``` -### CodeExchangeParams - -The `codeExchange` function receives these parameters: - -```typescript -interface CodeExchangeParams { - code: string; // Authorization code from OAuth callback - state: string; // State parameter for CSRF protection - codeVerifier?: string; // PKCE code verifier (optional) - scopes: string[]; // Requested scopes - provider?: string; // OAuth provider (google, microsoft, etc.) - clientId: string; // Your Nylas Client ID - redirectUri: string; // OAuth redirect URI -} -``` - ## API ### `connect(options?)` From f4986bed573efe604a7c7ffe01c4b80825d4422a Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Tue, 7 Oct 2025 19:54:26 +0200 Subject: [PATCH 5/9] modify the changeset --- .changeset/custom-code-exchange.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/custom-code-exchange.md b/.changeset/custom-code-exchange.md index 13f6622..8c6a08a 100644 --- a/.changeset/custom-code-exchange.md +++ b/.changeset/custom-code-exchange.md @@ -7,11 +7,11 @@ 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) => { - // Handle code exchange on your backend const response = await fetch('/api/auth/exchange', { method: 'POST', body: JSON.stringify(params), From 6d11df2f6f37762815c495358c5f1bfe1babde5d Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:53:56 -0400 Subject: [PATCH 6/9] Rollback runner to blacksmith-2vcpu-ubuntu-2204 --- .github/workflows/pr-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index e2ef065..74334d4 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: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 15 steps: From c4684dd219af57f150a8ce1fb111d779789621cd Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:54:07 -0400 Subject: [PATCH 7/9] Rollback runner to blacksmith-2vcpu-ubuntu-2204 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c691a3..3671d0d 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: blacksmith-2vcpu-ubuntu-2204 permissions: contents: write # to create release commits and tags pull-requests: write # to create release PRs From 28feb8a575570439cfe966e9ed93d72bc33356b7 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:13:40 -0400 Subject: [PATCH 8/9] Revert to ubuntu-latest temporarily. --- .github/workflows/pr-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 74334d4..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-2204 + runs-on: ubuntu-latest timeout-minutes: 15 steps: From 85ccc4fb5daa02cee29a3d9cbfbce5f47b3348f2 Mon Sep 17 00:00:00 2001 From: Aaron de Mello <314152+AaronDDM@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:13:54 -0400 Subject: [PATCH 9/9] Revert to ubuntu-latest temporarily. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3671d0d..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-2204 + runs-on: ubuntu-latest permissions: contents: write # to create release commits and tags pull-requests: write # to create release PRs