From 738e3c439ac6d0b5781d4b75b46db1476dadd917 Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Thu, 2 Oct 2025 14:49:29 +0200 Subject: [PATCH 1/3] Allow using external idp --- .../add-identity-provider-token-support.md | 8 +++ packages/nylas-connect/README.md | 52 +++++++++++++++++++ packages/nylas-connect/src/connect-client.ts | 42 +++++++++++++-- packages/nylas-connect/src/index.ts | 1 + packages/nylas-connect/src/types.ts | 7 +++ 5 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 .changeset/add-identity-provider-token-support.md diff --git a/.changeset/add-identity-provider-token-support.md b/.changeset/add-identity-provider-token-support.md new file mode 100644 index 0000000..d183b89 --- /dev/null +++ b/.changeset/add-identity-provider-token-support.md @@ -0,0 +1,8 @@ +--- +"@nylas/connect": minor +--- +- Added `IdentityProviderTokenCallback` type for providing JWT tokens +- Added optional `identityProviderToken` callback to `ConnectConfig` +- Token exchange now uses JSON format instead of form-encoded requests +- Added `idp_claims` field to token exchange when IDP token is provided + diff --git a/packages/nylas-connect/README.md b/packages/nylas-connect/README.md index 39f9802..9bf410c 100644 --- a/packages/nylas-connect/README.md +++ b/packages/nylas-connect/README.md @@ -284,6 +284,58 @@ Match your Nylas account region: Automatic. @nylas/connect handles token refresh in the background. + +# External Identity Provider Integration Example + +This example demonstrates how to use the new `identityProviderToken` callback feature to integrate external identity providers (via JWKS) with Nylas Connect. + +## Basic Usage + +```typescript +import { NylasConnect } from '@nylas/connect'; + +// Example: Using a function that returns a JWT token +const connect = new NylasConnect({ + clientId: 'your-client-id', + redirectUri: 'http://localhost:3000/auth/callback', + + // New feature: Identity provider token callback + identityProviderToken: async () => { + // Your logic to get the JWT token from your external identity provider + // This could be from your own auth system, a third-party service, etc. + const token = await getJWTFromYourIdentityProvider(); + return token; // Return the JWT string, or null if not available + } +}); + +// The rest works the same as before +const result = await connect.connect({ method: 'popup' }); +``` + + +## How It Works + +1. When you call `connect.connect()`, the authentication flow proceeds normally +2. During the token exchange step (when exchanging the authorization code for access tokens), the `identityProviderToken` callback is called +3. If the callback returns a JWT token, it's sent to Nylas as the `idp_claims` parameter +4. If the callback returns `null` or throws an error: + - Returning `null`: The auth flow continues without IDP claims + - Throwing an error: The entire token exchange fails with a `NETWORK_ERROR` event + +## Error Handling + +If the `identityProviderToken` callback throws an error, the entire authentication flow will fail with a `NETWORK_ERROR` event. You can listen for this event to handle IDP-related errors: + +```typescript +connect.onConnectStateChange((event, session, data) => { + if (event === 'NETWORK_ERROR' && data?.operation === 'identity_provider_token_callback') { + // Handle IDP token callback error + console.error('IDP token error:', data.error); + } +}); +``` + + ## License MIT © [Nylas](https://nylas.com) \ No newline at end of file diff --git a/packages/nylas-connect/src/connect-client.ts b/packages/nylas-connect/src/connect-client.ts index 1c44241..43718a1 100644 --- a/packages/nylas-connect/src/connect-client.ts +++ b/packages/nylas-connect/src/connect-client.ts @@ -41,8 +41,7 @@ import { * Modern Nylas authentication client */ export class NylasConnect { - private config: Required> & - Pick; + private config: ConnectConfig; private storage: TokenStorage; private connectStateCallbacks: Set = new Set(); @@ -76,6 +75,7 @@ export class NylasConnect { autoHandleCallback: resolvedConfig.autoHandleCallback!, logLevel: resolvedConfig.logLevel, codeExchange: resolvedConfig.codeExchange, + identityProviderToken: resolvedConfig.identityProviderToken, }; // Configure logger based on config @@ -129,6 +129,7 @@ export class NylasConnect { autoHandleCallback: config.autoHandleCallback ?? true, logLevel: config.logLevel, codeExchange: config.codeExchange, + identityProviderToken: config.identityProviderToken, }; } @@ -985,7 +986,7 @@ export class NylasConnect { codeVerifier, ); - const payload = { + const payload: Record = { client_id: this.config.clientId, redirect_uri: this.config.redirectUri, code, @@ -993,13 +994,44 @@ export class NylasConnect { code_verifier: codeVerifier, }; + // Get identity provider token if callback is configured + if (this.config.identityProviderToken) { + try { + logger.debug("Calling identity provider token callback"); + const idpToken = await this.config.identityProviderToken(); + + if (idpToken) { + payload.idp_claims = idpToken; + logger.debug("Added idp_claims to token exchange payload"); + } else { + logger.debug("Identity provider token callback returned null/empty"); + } + } catch (error) { + logger.error("Identity provider token callback failed", error); + + const idpError = new NetworkError( + "Identity provider token callback failed", + "Failed to retrieve external identity provider token", + error as Error, + ); + + // Emit NETWORK_ERROR event for IDP callback failures + this.triggerConnectStateChange("NETWORK_ERROR", null, { + operation: "identity_provider_token_callback", + error: idpError, + }); + + throw idpError; + } + } + try { const response = await fetch(`${this.config.apiUrl}/connect/token`, { method: "POST", headers: { - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", }, - body: new URLSearchParams(payload).toString(), + body: JSON.stringify(payload), }); if (!response.ok) { diff --git a/packages/nylas-connect/src/index.ts b/packages/nylas-connect/src/index.ts index b5fb143..309e109 100644 --- a/packages/nylas-connect/src/index.ts +++ b/packages/nylas-connect/src/index.ts @@ -20,6 +20,7 @@ export type { ConnectEventData, ConnectStateChangeCallback, SessionData, + IdentityProviderTokenCallback, // OAuth scope types GoogleScope, MicrosoftScope, diff --git a/packages/nylas-connect/src/types.ts b/packages/nylas-connect/src/types.ts index 5c756e0..724e294 100644 --- a/packages/nylas-connect/src/types.ts +++ b/packages/nylas-connect/src/types.ts @@ -99,6 +99,11 @@ export type CodeExchangeMethod = ( params: CodeExchangeParams, ) => Promise; +export type IdentityProviderTokenCallback = () => + | Promise + | string + | null; + /** * Core configuration for NylasConnect */ @@ -123,6 +128,8 @@ export interface ConnectConfig { logLevel?: LogLevel | "off"; /** Custom code exchange method - if provided, will be used instead of built-in token exchange */ codeExchange?: CodeExchangeMethod; + /** Optional callback to provide external identity provider JWT token for idp_claims */ + identityProviderToken?: IdentityProviderTokenCallback; } /** From 6965dc6657ea949f0a4aac91f425188a279600b6 Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Thu, 2 Oct 2025 14:50:48 +0200 Subject: [PATCH 2/3] add test --- .../nylas-connect/src/connect-client.test.ts | 407 +++++++++++++++++- 1 file changed, 406 insertions(+), 1 deletion(-) diff --git a/packages/nylas-connect/src/connect-client.test.ts b/packages/nylas-connect/src/connect-client.test.ts index a70f2da..23c6909 100644 --- a/packages/nylas-connect/src/connect-client.test.ts +++ b/packages/nylas-connect/src/connect-client.test.ts @@ -1167,7 +1167,7 @@ describe("NylasConnect (custom code exchange)", () => { "https://api.us.nylas.com/v3/connect/token", expect.objectContaining({ method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, + headers: { "Content-Type": "application/json" }, }), ); }); @@ -1188,6 +1188,411 @@ describe("NylasConnect (custom code exchange)", () => { } }); +describe("NylasConnect (Identity Provider Token)", () => { + const clientId = "client_123"; + const redirectUri = "https://app.example/callback"; + + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("should include idp_claims in token exchange when identityProviderToken callback returns a token", async () => { + const mockIdpToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + const mockIdentityProviderToken = vi.fn().mockResolvedValue(mockIdpToken); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ + sub: "user_1", + email: "alice@example.com", + name: "Alice", + provider: "google", + email_verified: true, + }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access_abc", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: "email profile", + grant_id: "grant_1", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + // Verify the callback was called + expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); + + // Verify the fetch was called with JSON content type and idp_claims + expect(mockFetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + idp_claims: mockIdpToken, + }), + }), + ); + + // Verify the result is still correct + expect(result.accessToken).toBe("access_abc"); + expect(result.grantId).toBe("grant_1"); + }); + + it("should not include idp_claims when identityProviderToken callback returns null", async () => { + const mockIdentityProviderToken = vi.fn().mockResolvedValue(null); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ + sub: "user_1", + email: "alice@example.com", + name: "Alice", + provider: "google", + email_verified: true, + }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access_abc", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: "email profile", + grant_id: "grant_1", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + // Verify the fetch was called with JSON format but no idp_claims + expect(mockFetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field + }), + }), + ); + + // Verify the result is correct + expect(result.accessToken).toBe("access_abc"); + expect(result.grantId).toBe("grant_1"); + }); + + it("should handle empty string return from identityProviderToken callback", async () => { + const mockIdentityProviderToken = vi.fn().mockResolvedValue(""); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ + sub: "user_1", + email: "alice@example.com", + name: "Alice", + provider: "google", + email_verified: true, + }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access_abc", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: "email profile", + grant_id: "grant_1", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + // Verify the callback was called + expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); + + // Verify the fetch was called without idp_claims (empty string is falsy) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + body: JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field should be present for empty string + }), + }), + ); + }); + + it("should work without identityProviderToken callback (backward compatibility)", async () => { + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + // No identityProviderToken callback + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ + sub: "user_1", + email: "alice@example.com", + name: "Alice", + provider: "google", + email_verified: true, + }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access_abc", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: "email profile", + grant_id: "grant_1", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const result = await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + // Verify the fetch was called with JSON format but no idp_claims + expect(mockFetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + // No idp_claims field + }), + }), + ); + + // Verify the result is correct + expect(result.accessToken).toBe("access_abc"); + expect(result.grantId).toBe("grant_1"); + }); + + it("should work with synchronous identityProviderToken callback", async () => { + const mockIdpToken = "sync.jwt.token"; + const mockIdentityProviderToken = vi.fn().mockReturnValue(mockIdpToken); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint + const header = base64url({ alg: "none", typ: "JWT" }); + const payload = base64url({ + sub: "user_1", + email: "alice@example.com", + name: "Alice", + provider: "google", + email_verified: true, + }); + const idToken = `${header}.${payload}.sig`; + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access_abc", + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: "email profile", + grant_id: "grant_1", + }), + }); + vi.stubGlobal("fetch", mockFetch); + + await auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ); + + // Verify the callback was called + expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); + + // Verify the fetch was called with the sync token + expect(mockFetch).toHaveBeenCalledWith( + "https://api.us.nylas.com/v3/connect/token", + expect.objectContaining({ + body: JSON.stringify({ + client_id: clientId, + redirect_uri: redirectUri, + code: "auth_code_1", + grant_type: "authorization_code", + code_verifier: "verifier123", + idp_claims: mockIdpToken, + }), + }), + ); + }); + + it("should fail token exchange when identityProviderToken callback throws an error", async () => { + const mockError = new Error("IDP service unavailable"); + const mockIdentityProviderToken = vi.fn().mockRejectedValue(mockError); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Mock token endpoint (should not be called) + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + // Verify the callback error is thrown + await expect( + auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ), + ).rejects.toThrow("Identity provider token callback failed"); + + // Verify the callback was called + expect(mockIdentityProviderToken).toHaveBeenCalledTimes(1); + + // Verify the token endpoint was never called due to callback failure + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should emit NETWORK_ERROR event when identityProviderToken callback fails", async () => { + const mockError = new Error("IDP service unavailable"); + const mockIdentityProviderToken = vi.fn().mockRejectedValue(mockError); + + const auth = new NylasConnect({ + clientId, + redirectUri, + apiUrl: "https://api.us.nylas.com", + identityProviderToken: mockIdentityProviderToken, + }); + + // Prime storage by calling connect() first + await auth.connect(); + + // Set up event listener + const mockEventCallback = vi.fn(); + auth.onConnectStateChange(mockEventCallback); + + // Mock token endpoint (should not be called) + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + // Attempt the callback + await expect( + auth.handleRedirectCallback( + `${redirectUri}?code=auth_code_1&state=stateXYZ`, + ), + ).rejects.toThrow("Identity provider token callback failed"); + + // Verify NETWORK_ERROR event was emitted for IDP callback failure + expect(mockEventCallback).toHaveBeenCalledWith( + "NETWORK_ERROR", + null, + expect.objectContaining({ + operation: "identity_provider_token_callback", + error: expect.objectContaining({ + message: "Identity provider token callback failed", + description: "Failed to retrieve external identity provider token", + }), + }), + ); + }); +}); + describe("NylasConnect (API URL normalization)", () => { const clientId = "client_123"; const redirectUri = "https://app.example/callback"; From 4ddd5fba2e1318c3118c97bd997f97a8c2e2acce Mon Sep 17 00:00:00 2001 From: Dan Radenkovic Date: Thu, 16 Oct 2025 16:26:21 +0200 Subject: [PATCH 3/3] fix issue with token validation --- packages/nylas-connect/src/connect-client.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/nylas-connect/src/connect-client.ts b/packages/nylas-connect/src/connect-client.ts index 43718a1..bf0abd0 100644 --- a/packages/nylas-connect/src/connect-client.ts +++ b/packages/nylas-connect/src/connect-client.ts @@ -719,13 +719,9 @@ export class NylasConnect { } try { - const response = await fetch(`${this.config.apiUrl}/connect/tokeninfo`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `access_token=${encodeURIComponent(accessToken)}`, - }); + const response = await fetch( + `${this.config.apiUrl}/connect/tokeninfo?access_token=${encodeURIComponent(accessToken)}`, + ); const data = await response.json(); const isValid = !!(response.ok && data?.data);