From 0d7b0f999c8631b7d0d5d98360bc438bd01a0afc Mon Sep 17 00:00:00 2001 From: Steve Morin Date: Thu, 3 Sep 2026 17:23:48 -0700 Subject: [PATCH 1/2] feat(oauth): allow customizing provider display metadata --- docs/utils-reference/oauth/OAuthService.md | 63 +++++++------ src/oauth/OAuthService.ts | 28 +++--- src/oauth/types.ts | 2 + test/OAuthService.test.ts | 104 ++++++++++++++++++--- 4 files changed, 140 insertions(+), 57 deletions(-) diff --git a/docs/utils-reference/oauth/OAuthService.md b/docs/utils-reference/oauth/OAuthService.md index ead8f78..73d1ca6 100644 --- a/docs/utils-reference/oauth/OAuthService.md +++ b/docs/utils-reference/oauth/OAuthService.md @@ -102,7 +102,6 @@ Google has verification processes based on the required scopes for your extensio Creating your own Google client ID is more tedious than other processes, so we’ve created a page to assist you: [Getting a Google client ID](./getting-google-client-id.md) {% endhint %} - ##### Signature ```ts @@ -154,10 +153,14 @@ const linear = OAuthService.linear({ scope: "read write" }); const secondWorkspace = OAuthService.linear({ scope: "read write", providerId: "linear-workspace-2", + providerName: "Linear — Acme", + description: "Connect to Acme as you@acme.com", extraParameters: { prompt: "consent" }, }); ``` +`providerId` is the stable token-storage namespace. `providerName` and `description` are display metadata for the OAuth client, so they can identify the specific account, workspace, or site without changing where its tokens are stored. + #### Slack ##### Signature @@ -212,34 +215,38 @@ const zoom = OAuthService.zoom({ ### ProviderOptions -| Property Name | Description | Type | -| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| clientId\* | The app's client ID | `string` | -| scope\* | The scope of the access requested from the provider | `string` \| `Array` | -| providerId | The provider ID used to namespace the token storage of the internally-constructed `OAuth.PKCEClient`. Defaults to the service's name (e.g. `linear`). Override it to keep several independent logins for the same provider (e.g. one per workspace). | `string` | -| extraParameters | Extra parameters for the authorization request, merged over the provider's defaults (caller wins) | `Record` | -| authorizeUrl\* | The URL to start the OAuth flow | `string` | -| tokenUrl\* | The URL to exchange the authorization code for an access token | `string` | -| refreshTokenUrl | The URL to refresh the access token if applicable | `string` | -| personalAccessToken | A personal token if the provider supports it | `string` | -| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | -| bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | -| tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | -| tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | +| Property Name | Description | Type | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| clientId\* | The app's client ID | `string` | +| scope\* | The scope of the access requested from the provider | `string` \| `Array` | +| providerId | The provider ID used to namespace the token storage of the internally-constructed `OAuth.PKCEClient`. Defaults to the service's name (e.g. `linear`). Override it to keep several independent logins for the same provider (e.g. one per workspace). | `string` | +| providerName | The provider name displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's name. Override it to distinguish multiple accounts, workspaces, or sites. | `string` | +| description | The description displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's connection message. | `string` | +| extraParameters | Extra parameters for the authorization request, merged over the provider's defaults (caller wins) | `Record` | +| authorizeUrl\* | The URL to start the OAuth flow | `string` | +| tokenUrl\* | The URL to exchange the authorization code for an access token | `string` | +| refreshTokenUrl | The URL to refresh the access token if applicable | `string` | +| personalAccessToken | A personal token if the provider supports it | `string` | +| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | +| bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | +| tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | +| tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | ### ProviderWithDefaultClientOptions -| Property Name | Description | Type | -| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| scope\* | The scope of the access requested from the provider | `string` \| `Array` | +| Property Name | Description | Type | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| scope\* | The scope of the access requested from the provider | `string` \| `Array` | | providerId | The provider ID used to namespace the token storage of the internally-constructed `OAuth.PKCEClient`. Defaults to the service's name (e.g. `linear`). Override it to keep several independent logins for the same provider (e.g. one per workspace). | `string` | -| extraParameters | Extra parameters for the authorization request, merged over the provider's defaults (caller wins) | `Record` | -| clientId | The app's client ID | `string` | -| authorizeUrl | The URL to start the OAuth flow | `string` | -| tokenUrl | The URL to exchange the authorization code for an access token | `string` | -| refreshTokenUrl | The URL to refresh the access token if applicable | `string` | -| personalAccessToken | A personal token if the provider supports it | `string` | -| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | -| bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | -| tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | -| tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | +| providerName | The provider name displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's name. Override it to distinguish multiple accounts, workspaces, or sites. | `string` | +| description | The description displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's connection message. | `string` | +| extraParameters | Extra parameters for the authorization request, merged over the provider's defaults (caller wins) | `Record` | +| clientId | The app's client ID | `string` | +| authorizeUrl | The URL to start the OAuth flow | `string` | +| tokenUrl | The URL to exchange the authorization code for an access token | `string` | +| refreshTokenUrl | The URL to refresh the access token if applicable | `string` | +| personalAccessToken | A personal token if the provider supports it | `string` | +| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | +| bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | +| tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | +| tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | diff --git a/src/oauth/OAuthService.ts b/src/oauth/OAuthService.ts index 5070804..4dd71d7 100644 --- a/src/oauth/OAuthService.ts +++ b/src/oauth/OAuthService.ts @@ -74,12 +74,12 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Asana", + providerName: options.providerName ?? "Asana", providerIcon: `data:image/svg+xml,${encodeURIComponent( ``, )}`, providerId: options.providerId ?? "asana", - description: "Connect your Asana account", + description: options.description ?? "Connect your Asana account", }), clientId: options.clientId ?? PROVIDER_CLIENT_IDS.asana, authorizeUrl: options.authorizeUrl ?? "https://asana.oauth.raycast.com/authorize", @@ -107,7 +107,7 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "GitHub", + providerName: options.providerName ?? "GitHub", providerIcon: { source: `data:image/svg+xml,${encodeURIComponent( ``, @@ -116,7 +116,7 @@ export class OAuthService implements OAuthServiceOptions { tintColor: Color.PrimaryText, }, providerId: options.providerId ?? "github", - description: "Connect your GitHub account", + description: options.description ?? "Connect your GitHub account", }), clientId: options.clientId ?? PROVIDER_CLIENT_IDS.github, authorizeUrl: options.authorizeUrl ?? "https://github.oauth.raycast.com/authorize", @@ -149,12 +149,12 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.AppURI, - providerName: "Google", + providerName: options.providerName ?? "Google", providerIcon: `data:image/svg+xml,${encodeURIComponent( ``, )}`, providerId: options.providerId ?? "google", - description: "Connect your Google account", + description: options.description ?? "Connect your Google account", }), clientId: options.clientId, authorizeUrl: options.authorizeUrl ?? "https://accounts.google.com/o/oauth2/v2/auth", @@ -187,12 +187,12 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Jira", + providerName: options.providerName ?? "Jira", providerIcon: `data:image/svg+xml,${encodeURIComponent( ``, )}`, providerId: options.providerId ?? "jira", - description: "Connect your Jira account", + description: options.description ?? "Connect your Jira account", }), clientId: options.clientId, authorizeUrl: options.authorizeUrl ?? "https://auth.atlassian.com/authorize", @@ -220,7 +220,7 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Linear", + providerName: options.providerName ?? "Linear", providerIcon: { source: { light: `data:image/svg+xml,${encodeURIComponent( @@ -232,7 +232,7 @@ export class OAuthService implements OAuthServiceOptions { }, }, providerId: options.providerId ?? "linear", - description: "Connect your Linear account", + description: options.description ?? "Connect your Linear account", }), clientId: options.clientId ?? PROVIDER_CLIENT_IDS.linear, authorizeUrl: options.authorizeUrl ?? "https://linear.oauth.raycast.com/authorize", @@ -262,12 +262,12 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Slack", + providerName: options.providerName ?? "Slack", providerIcon: `data:image/svg+xml,${encodeURIComponent( ``, )}`, providerId: options.providerId ?? "slack", - description: "Connect your Slack account", + description: options.description ?? "Connect your Slack account", }), clientId: options.clientId ?? PROVIDER_CLIENT_IDS.slack, authorizeUrl: options.authorizeUrl ?? "https://slack.oauth.raycast.com/authorize", @@ -311,12 +311,12 @@ export class OAuthService implements OAuthServiceOptions { return new OAuthService({ client: new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, - providerName: "Zoom", + providerName: options.providerName ?? "Zoom", providerIcon: `data:image/svg+xml,${encodeURIComponent( ``, )}`, providerId: options.providerId ?? "zoom", - description: "Connect your Zoom account", + description: options.description ?? "Connect your Zoom account", }), clientId: options.clientId, authorizeUrl: options.authorizeUrl ?? "https://zoom.us/oauth/authorize", diff --git a/src/oauth/types.ts b/src/oauth/types.ts index aa27dce..c8735b4 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -26,6 +26,8 @@ export interface OAuthServiceOptions { type BaseProviderOptions = { scope: string; providerId?: string; + providerName?: string; + description?: string; extraParameters?: Record; personalAccessToken?: string; authorizeUrl?: string; diff --git a/test/OAuthService.test.ts b/test/OAuthService.test.ts index 89c921f..9a0d4ca 100644 --- a/test/OAuthService.test.ts +++ b/test/OAuthService.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { OAuthService } from "../src/oauth/OAuthService"; -type MockedClient = { options: { providerId?: string; providerName?: string } }; +type MockedClient = { options: { providerId?: string; providerName: string; description?: string } }; function clientOptions(service: OAuthService) { return (service.client as unknown as MockedClient).options; @@ -46,24 +46,98 @@ describe("OAuthService built-in providers", () => { expect(service.extraParameters).toEqual({ audience: "api.atlassian.com" }); }); - // Table test: the change touches all seven providers — each must keep its default - // providerId when the option is omitted and accept an override when given. - const providers: Array<{ name: string; make: (extra?: object) => OAuthService }> = [ - { name: "asana", make: (extra = {}) => OAuthService.asana({ scope: "default", ...extra }) }, - { name: "github", make: (extra = {}) => OAuthService.github({ scope: "repo", ...extra }) }, - { name: "google", make: (extra = {}) => OAuthService.google({ clientId: "cid", scope: "email", ...extra }) }, - { name: "jira", make: (extra = {}) => OAuthService.jira({ clientId: "cid", scope: "read:jira-user", ...extra }) }, - { name: "linear", make: (extra = {}) => OAuthService.linear({ scope: "read write", ...extra }) }, - { name: "slack", make: (extra = {}) => OAuthService.slack({ scope: "emoji:read", ...extra }) }, - { name: "zoom", make: (extra = {}) => OAuthService.zoom({ clientId: "cid", scope: "meeting:write", ...extra }) }, + // Table tests cover all seven built-in providers so an omitted option always preserves + // the existing PKCE client metadata and each option can be overridden independently. + const providers: Array<{ + id: string; + providerName: string; + description: string; + make: (extra?: object) => OAuthService; + }> = [ + { + id: "asana", + providerName: "Asana", + description: "Connect your Asana account", + make: (extra = {}) => OAuthService.asana({ scope: "default", ...extra }), + }, + { + id: "github", + providerName: "GitHub", + description: "Connect your GitHub account", + make: (extra = {}) => OAuthService.github({ scope: "repo", ...extra }), + }, + { + id: "google", + providerName: "Google", + description: "Connect your Google account", + make: (extra = {}) => OAuthService.google({ clientId: "cid", scope: "email", ...extra }), + }, + { + id: "jira", + providerName: "Jira", + description: "Connect your Jira account", + make: (extra = {}) => OAuthService.jira({ clientId: "cid", scope: "read:jira-user", ...extra }), + }, + { + id: "linear", + providerName: "Linear", + description: "Connect your Linear account", + make: (extra = {}) => OAuthService.linear({ scope: "read write", ...extra }), + }, + { + id: "slack", + providerName: "Slack", + description: "Connect your Slack account", + make: (extra = {}) => OAuthService.slack({ scope: "emoji:read", ...extra }), + }, + { + id: "zoom", + providerName: "Zoom", + description: "Connect your Zoom account", + make: (extra = {}) => OAuthService.zoom({ clientId: "cid", scope: "meeting:write", ...extra }), + }, ]; - it.each(providers)("keeps the default providerId for $name and accepts an override", ({ name, make }) => { - expect(clientOptions(make()).providerId).toBe(name); - expect(clientOptions(make({ providerId: `${name}-second-account` })).providerId).toBe(`${name}-second-account`); + it.each(providers)("keeps the default PKCE client metadata for $id", ({ id, providerName, description, make }) => { + expect(clientOptions(make())).toMatchObject({ providerId: id, providerName, description }); }); - it.each(providers)("passes extraParameters through for $name without dropping provider defaults", ({ make }) => { + it.each(providers)("accepts custom PKCE client metadata for $id", ({ id, make }) => { + expect( + clientOptions( + make({ + providerId: `${id}-second-account`, + providerName: "Work Account", + description: "Connect the work account", + }), + ), + ).toMatchObject({ + providerId: `${id}-second-account`, + providerName: "Work Account", + description: "Connect the work account", + }); + }); + + it.each(providers)( + "allows independent providerName and description overrides for $id", + ({ providerName, description, make }) => { + expect(clientOptions(make({ providerName: "Work Account" }))).toMatchObject({ + providerName: "Work Account", + description, + }); + expect(clientOptions(make({ description: "Connect the work account" }))).toMatchObject({ + providerName, + description: "Connect the work account", + }); + }, + ); + + it.each(providers)("keeps the default providerId for $id and accepts an override", ({ id, make }) => { + expect(clientOptions(make()).providerId).toBe(id); + expect(clientOptions(make({ providerId: `${id}-second-account` })).providerId).toBe(`${id}-second-account`); + }); + + it.each(providers)("passes extraParameters through for $id without dropping provider defaults", ({ make }) => { const service = make({ extraParameters: { custom_param: "x" } }); expect(service.extraParameters).toMatchObject({ custom_param: "x" }); }); From 4c61f754e11227393a1cb3dc9234c34ed28d9387 Mon Sep 17 00:00:00 2001 From: Steve Morin Date: Thu, 3 Sep 2026 17:32:44 -0700 Subject: [PATCH 2/2] docs(oauth): correct provider option types --- docs/utils-reference/oauth/OAuthService.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/utils-reference/oauth/OAuthService.md b/docs/utils-reference/oauth/OAuthService.md index 73d1ca6..6eb55dd 100644 --- a/docs/utils-reference/oauth/OAuthService.md +++ b/docs/utils-reference/oauth/OAuthService.md @@ -141,7 +141,7 @@ const jira = OAuthService.jira({ ##### Signature ```ts -OAuthService.linear: (options: ProviderOptions) => OAuthService +OAuthService.linear: (options: ProviderWithDefaultClientOptions) => OAuthService ``` ##### Example @@ -207,7 +207,7 @@ const zoom = OAuthService.zoom({ | tokenUrl\* | The URL to exchange the authorization code for an access token | `string` | | refreshTokenUrl | The URL to refresh the access token if applicable | `string` | | personalAccessToken | A personal token if the provider supports it | `string` | -| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | +| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `(params: OnAuthorizeParams) => void` | | extraParameters | The extra parameters you may need for the authorization request | `Record` | | bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | | tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | @@ -218,7 +218,7 @@ const zoom = OAuthService.zoom({ | Property Name | Description | Type | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | clientId\* | The app's client ID | `string` | -| scope\* | The scope of the access requested from the provider | `string` \| `Array` | +| scope\* | The scope of the access requested from the provider | `string` | | providerId | The provider ID used to namespace the token storage of the internally-constructed `OAuth.PKCEClient`. Defaults to the service's name (e.g. `linear`). Override it to keep several independent logins for the same provider (e.g. one per workspace). | `string` | | providerName | The provider name displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's name. Override it to distinguish multiple accounts, workspaces, or sites. | `string` | | description | The description displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's connection message. | `string` | @@ -227,7 +227,7 @@ const zoom = OAuthService.zoom({ | tokenUrl\* | The URL to exchange the authorization code for an access token | `string` | | refreshTokenUrl | The URL to refresh the access token if applicable | `string` | | personalAccessToken | A personal token if the provider supports it | `string` | -| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | +| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `(params: OnAuthorizeParams) => void` | | bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | | tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | | tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | @@ -236,7 +236,7 @@ const zoom = OAuthService.zoom({ | Property Name | Description | Type | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| scope\* | The scope of the access requested from the provider | `string` \| `Array` | +| scope\* | The scope of the access requested from the provider | `string` | | providerId | The provider ID used to namespace the token storage of the internally-constructed `OAuth.PKCEClient`. Defaults to the service's name (e.g. `linear`). Override it to keep several independent logins for the same provider (e.g. one per workspace). | `string` | | providerName | The provider name displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's name. Override it to distinguish multiple accounts, workspaces, or sites. | `string` | | description | The description displayed by the internally-constructed `OAuth.PKCEClient`. Defaults to the built-in provider's connection message. | `string` | @@ -246,7 +246,7 @@ const zoom = OAuthService.zoom({ | tokenUrl | The URL to exchange the authorization code for an access token | `string` | | refreshTokenUrl | The URL to refresh the access token if applicable | `string` | | personalAccessToken | A personal token if the provider supports it | `string` | -| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `string` | +| onAuthorize | A callback function that is called once the user has been properly logged in through OAuth when used with `withAccessToken` | `(params: OnAuthorizeParams) => void` | | bodyEncoding | Specifies the format for sending the body of the request. | `json` \| `url-encoded` | | tokenResponseParser | Some providers returns some non-standard token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` | | tokenRefreshResponseParser | Some providers returns some non-standard refresh token responses. Specifies how to parse the JSON response to get the access token | `(response: unknown) => OAuth.TokenResponse` |