From 0bd02bb3a4070fdd1628f7b7ac8b906cc961b679 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:32:10 +0200 Subject: [PATCH] Add the OAuth2 password grant for tokens without a refresh token Datto RMM issues 100-hour tokens with no refresh token, so a connector set up with the authorization-code flow stopped working after 100 hours. The password grant asks the token URL again with the stored username and password. The grant can now be chosen in the connector forms, and the REST and GraphQL engines retry a 401 for machine grants too. --- docs/connectors/rest.md | 36 ++++- packages/backend/src/adapters/env-var-meta.ts | 3 +- .../connectors/connector-setup-status.util.ts | 3 +- .../connectors/connectors.controller.spec.ts | 73 +++++++++ .../src/connectors/connectors.controller.ts | 72 +++++++-- .../src/connectors/engines/graphql.engine.ts | 7 +- .../engines/oauth2-token.service.spec.ts | 138 +++++++++++++++++ .../engines/oauth2-token.service.ts | 78 +++++++--- .../connectors/engines/rest.engine.spec.ts | 29 ++++ .../src/connectors/engines/rest.engine.ts | 7 +- .../src/connectors/oauth-grant.util.spec.ts | 21 +++ .../src/connectors/oauth-grant.util.ts | 21 +++ .../frontend/src/app/connectors/[id]/page.tsx | 72 ++++++++- .../frontend/src/app/connectors/new/page.tsx | 92 ++++++++++-- packages/frontend/src/lib/api.ts | 20 +++ .../tests/e2e/connector-oauth-grant.spec.ts | 139 ++++++++++++++++++ 16 files changed, 745 insertions(+), 66 deletions(-) create mode 100644 packages/backend/src/connectors/oauth-grant.util.spec.ts create mode 100644 packages/backend/src/connectors/oauth-grant.util.ts create mode 100644 packages/frontend/tests/e2e/connector-oauth-grant.spec.ts diff --git a/docs/connectors/rest.md b/docs/connectors/rest.md index 6e94b00ef..37b073753 100644 --- a/docs/connectors/rest.md +++ b/docs/connectors/rest.md @@ -294,7 +294,7 @@ OAuth2 connectors use the **Authorization Code + PKCE** flow: #### Token endpoint authentication (`client_secret_basic`) By default the client credentials are sent **in the body** of the token request (`client_secret_post`). -Some providers — **Datto RMM** and **DATEV** among them — only accept them as an **HTTP Basic header** +Some providers — **DATEV** among them — only accept them as an **HTTP Basic header** (`Authorization: Basic base64(client_id:client_secret)`, RFC 6749 §2.3.1) and answer anything else with `401` at the code-exchange step. @@ -316,16 +316,38 @@ Notes: only `hasClientSecret` / `hasAccessToken` / `hasRefreshToken` booleans. - After switching the method, re-run **Authorize with Provider** so a token is fetched the new way. -Example — Datto RMM: +#### Grants without a sign-in (`client_credentials`, `password`) + +Some APIs issue a token from stored credentials and expect the client to ask again when it expires, +with no refresh token. Choose the grant under **Grant type** in the connector form, or set `grant`: + +- **Authorization code** (default): the flow above, renewed with the refresh token. +- **Client credentials** (`client_credentials`): the client ID and secret alone get the token. +- **Password** (`password`, RFC 6749 §4.3): the token request also carries a `username` and `password`. + +With the last two there is nothing to authorize: the connector requests a token on its first call, again +shortly before the token expires, and again when the API answers `401`. The client is sent as an HTTP Basic +header by default for these grants; pick **Client secret in body** if the provider wants it in the form. +The username and password, like the client secret, are never returned by `GET .../oauth-config`, only +`hasUsername` / `hasPassword`. + +```bash +curl -s -X PATCH http://localhost:4000/api/connectors/$CONNECTOR_ID/oauth-config \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"grant": "password", "username": "", "password": ""}' +``` + +Example — Datto RMM. Its tokens last 100 hours and come with no refresh token, so a connector set up with +the authorization-code flow stops working after 100 hours. Use the password grant: | Field | Value | |---|---| -| Authorization URL | `https://-api.centrastage.net/auth/oauth/authorize` | -| Token URL | `https://-api.centrastage.net/auth/oauth/token` | +| Grant type | **Password** | +| Username / Password | the API key / API secret key of the technical user | | Client ID / Secret | `public-client` / `public` | -| Token endpoint authentication | **HTTP Basic header** | - -The API key and secret key of the technical user are entered on the provider's own consent screen. +| Token URL | `https://-api.centrastage.net/auth/oauth/token` | +| Token endpoint authentication | **HTTP Basic header** (the default for this grant) | #### Signed client assertion (`private_key_jwt`) diff --git a/packages/backend/src/adapters/env-var-meta.ts b/packages/backend/src/adapters/env-var-meta.ts index a9c1b6266..492aee1bc 100644 --- a/packages/backend/src/adapters/env-var-meta.ts +++ b/packages/backend/src/adapters/env-var-meta.ts @@ -1,4 +1,5 @@ import { isSecretName } from '../connectors/connector-secrets.util'; +import { isMachineGrant } from '../connectors/oauth-grant.util'; import type { AdapterDefinition } from './catalog'; /** @@ -68,7 +69,7 @@ export function needsBrowserAuthorization(adapter: Pick { }); }); + it('switches a connector to the password grant (Datto RMM)', async () => { + const { controller, connectorsService } = build(oauthConnector()); + + await controller.updateOAuthConfig(req('ADMIN'), 'c1', { + grant: 'password', + username: 'api-key', + password: 'api-secret', + tokenAuthMethod: 'client_secret_basic', + }); + + expect(connectorsService.updateAuthConfigMerge).toHaveBeenCalledWith('c1', { + grant: 'password', + username: 'api-key', + password: 'api-secret', + tokenAuthMethod: 'client_secret_basic', + }); + }); + + it('stores the authorization-code flow as no grant, as every authorized connector has', async () => { + const { controller, connectorsService } = build(oauthConnector()); + + await controller.updateOAuthConfig(req('ADMIN'), 'c1', { grant: 'authorization_code' }); + + expect(connectorsService.updateAuthConfigMerge).toHaveBeenCalledWith('c1', { + grant: undefined, + }); + }); + + it('stores "in body" explicitly when switching to the password grant, whose default is Basic', async () => { + const { controller, connectorsService } = build(oauthConnector()); + + await controller.updateOAuthConfig(req('ADMIN'), 'c1', { + grant: 'password', + tokenAuthMethod: '', + }); + + expect(connectorsService.updateAuthConfigMerge).toHaveBeenCalledWith('c1', { + grant: 'password', + tokenAuthMethod: 'client_secret_post', + }); + }); + it('carries a corrected Etsy secret into the x-api-key header and the variables', async () => { // Production, 6 Oct 2026: the keystring was typed as the shared secret // at install, then corrected in the OAuth editor. authConfig.clientSecret @@ -406,6 +448,37 @@ describe('OAuth2 config endpoints', () => { expect(result.tokenAuthMethod).toBe('client_secret_basic'); }); + it('reports the grant, and only whether the password-grant credentials are set', async () => { + const { encrypt } = require('../common/crypto/encryption.util'); + const authConfig = encrypt( + JSON.stringify({ + grant: 'password', + clientId: 'public-client', + clientSecret: 'public', + username: 'datto-api-key', + password: 'datto-api-secret', + }), + VALID_ENCRYPTION_KEY, + ); + const { controller } = build(oauthConnector({ authConfig })); + + const result = await controller.getOAuthConfig(req('ADMIN'), 'c1'); + + expect(result).toMatchObject({ + grant: 'password', + tokenAuthMethod: 'client_secret_basic', + hasUsername: true, + hasPassword: true, + }); + expect(JSON.stringify(result)).not.toContain('datto-api'); + }); + + it('reports the authorization-code flow when no grant is stored', async () => { + const { controller } = build(oauthConnector({ authConfig: null })); + + expect((await controller.getOAuthConfig(req('ADMIN'), 'c1')).grant).toBe('authorization_code'); + }); + it('degrades gracefully when the stored config cannot be decrypted', async () => { // e.g. after an encryption-key rotation — the page must still load. const { controller } = build(oauthConnector({ authConfig: 'not-decryptable' })); diff --git a/packages/backend/src/connectors/connectors.controller.ts b/packages/backend/src/connectors/connectors.controller.ts index 40b8c89fe..25b76a7a8 100644 --- a/packages/backend/src/connectors/connectors.controller.ts +++ b/packages/backend/src/connectors/connectors.controller.ts @@ -54,6 +54,7 @@ import { decrypt } from '../common/crypto/encryption.util'; import { getAdapter } from '../adapters/catalog'; import { resolvePkce, resolveRestAuthorizeSettings } from './oauth-authorize-settings'; import { tokenEndpointUserAgent } from './engines/oauth2-token.service'; +import { isMachineGrant } from './oauth-grant.util'; import { DYNAMIC_CLIENT_KEY, planMcpAuthorization, @@ -307,6 +308,34 @@ class UpdateOAuthConfigDto { @IsIn(['', 'client_secret_post', 'client_secret_basic', 'basic', 'post', 'private_key_jwt']) tokenAuthMethod?: string; + @ApiPropertyOptional({ + description: + 'How the connector obtains its access token. `authorization_code` ' + + '(default, also sent as an empty string) signs in at the provider with ' + + '"Authorize with Provider" and renews with the refresh token. ' + + '`client_credentials` asks the token endpoint with the client id and ' + + 'secret alone. `password` (RFC 6749 §4.3) sends `username` and ' + + '`password` as well, and asks again whenever the token expires: for ' + + 'APIs that issue no refresh token, such as Datto RMM (username = API ' + + 'key, password = API secret, client `public-client` / `public` sent as ' + + 'HTTP Basic).', + enum: ['', 'authorization_code', 'client_credentials', 'password'], + }) + @IsOptional() + @IsString() + @IsIn(['', 'authorization_code', 'client_credentials', 'password']) + grant?: string; + + @ApiPropertyOptional({ description: 'Username for the password grant.' }) + @IsOptional() + @IsString() + username?: string; + + @ApiPropertyOptional({ description: 'Password for the password grant.' }) + @IsOptional() + @IsString() + password?: string; + @ApiPropertyOptional({ description: 'Whether "Authorize with Provider" uses PKCE (RFC 7636): a ' + @@ -1085,17 +1114,17 @@ export class ConnectorsController { const adapterSlug = (connector.config as { adapterSlug?: string } | null)?.adapterSlug; return { clientId: str(cfg.clientId), + grant: isMachineGrant(cfg.grant) ? String(cfg.grant) : 'authorization_code', authorizationUrl: str(cfg.authorizationUrl), tokenUrl: str(cfg.tokenUrl), scopes: str(cfg.scopes), // Report what the token service will actually do. With nothing stored, - // the client_credentials grant sends HTTP Basic, not body credentials; - // showing "in body" there made the form misstate every such connector. + // the client_credentials and password grants send HTTP Basic, not body + // credentials; showing "in body" there made the form misstate every + // such connector. tokenAuthMethod: str(cfg.tokenAuthMethod) || - (cfg.grant === 'client_credentials' - ? 'client_secret_basic' - : 'client_secret_post'), + (isMachineGrant(cfg.grant) ? 'client_secret_basic' : 'client_secret_post'), // What the authorization will do: the row's own setting, else the // catalog adapter's (see resolvePkce). pkce: resolvePkce( @@ -1103,6 +1132,10 @@ export class ConnectorsController { adapterSlug ? getAdapter(adapterSlug)?.connector.authConfig : undefined, ), hasClientSecret: !!cfg.clientSecret, + // The password grant's credentials (Datto's API key and secret) are + // never returned, like the client secret. + hasUsername: !!cfg.username, + hasPassword: !!cfg.password, hasAccessToken: !!cfg.accessToken, hasRefreshToken: !!cfg.refreshToken, }; @@ -1117,8 +1150,10 @@ export class ConnectorsController { 'endpoints established during authorization are preserved. Omitted ' + 'fields keep their current value; send an empty string to clear one. ' + 'Use `tokenAuthMethod` when a provider rejects credentials sent in the ' + - 'request body (RFC 6749 §2.3.1 client_secret_basic) — Datto RMM and ' + - 'DATEV both require it.', + 'request body (RFC 6749 §2.3.1 client_secret_basic) — DATEV requires ' + + 'it. Use `grant` for APIs that issue tokens without a user sign-in: ' + + '`client_credentials`, or `password` with `username` and `password` ' + + '(Datto RMM, whose 100-hour tokens come with no refresh token).', }) async updateOAuthConfig( @Req() req: any, @@ -1141,20 +1176,27 @@ export class ConnectorsController { 'authorizationUrl', 'tokenUrl', 'scopes', + 'username', + 'password', ] as const) { if (dto[key] !== undefined) patch[key] = dto[key]; } + if (dto.grant !== undefined) { + // The authorization-code flow is the default and is stored as no grant + // at all, which is what every connector authorized so far carries. + patch.grant = isMachineGrant(dto.grant) ? dto.grant : undefined; + } if (dto.tokenAuthMethod !== undefined) { // Empty = back to the default (credentials in the body). Except that - // "in the body" is not the default of the client_credentials grant — - // the token service sends HTTP Basic there unless told otherwise — so - // for that grant the form's "in body" choice has to be stored as such, - // or saving the form would quietly switch e.g. Amadeus back to Basic. + // "in the body" is not the default of the client_credentials and + // password grants — the token service sends HTTP Basic there unless + // told otherwise — so for those the form's "in body" choice has to be + // stored as such, or saving the form would quietly switch e.g. Amadeus + // back to Basic. + const grant = + dto.grant !== undefined ? dto.grant : this.readOAuthGrant(connector.authConfig); patch.tokenAuthMethod = - dto.tokenAuthMethod || - (this.readOAuthGrant(connector.authConfig) === 'client_credentials' - ? 'client_secret_post' - : undefined); + dto.tokenAuthMethod || (isMachineGrant(grant) ? 'client_secret_post' : undefined); } if (dto.pkce !== undefined) { // Stored either way: an explicit `true` must also beat a catalog diff --git a/packages/backend/src/connectors/engines/graphql.engine.ts b/packages/backend/src/connectors/engines/graphql.engine.ts index c1ef2664a..41633a3d5 100644 --- a/packages/backend/src/connectors/engines/graphql.engine.ts +++ b/packages/backend/src/connectors/engines/graphql.engine.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import axios, { AxiosError } from 'axios'; import { createUnblockerProxyAgent } from './unblocker-proxy-agent'; import { OAuth2TokenService } from './oauth2-token.service'; +import { canRenewToken } from '../oauth-grant.util'; import { LoginTokenService, LoginTokenAuthConfig, @@ -202,13 +203,13 @@ export class GraphqlEngine { return response.data.data; } catch (error) { - // OAuth2 auto-refresh: retry once on 401 + // OAuth2 auto-refresh: retry once on 401 (a refresh token, or a grant + // that asks for a new token by itself, such as Datto's password grant) if ( error instanceof AxiosError && error.response?.status === 401 && config.authType === 'OAUTH2' && - config.authConfig?.refreshToken && - config.authConfig?.tokenUrl + canRenewToken(config.authConfig) ) { this.logger.debug( 'OAuth2: access token expired, attempting refresh...', diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts index ffb7eea25..b22c932bd 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts @@ -569,6 +569,144 @@ describe('OAuth2TokenService', () => { }); }); + describe('password grant (Datto RMM)', () => { + // Datto RMM: the API key and secret are the username and password, the + // fixed client `public-client` / `public` goes in a Basic header, and the + // token lasts 100 hours with no refresh token. + const datto = (extra: Record = {}) => ({ + grant: 'password', + tokenUrl: 'https://merlot-api.centrastage.net/auth/oauth/token', + clientId: 'public-client', + clientSecret: 'public', + username: 'api-key', + password: 'api-secret', + ...extra, + }); + + it('posts grant_type=password with the credentials, the client as HTTP Basic', async () => { + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'datto-at', token_type: 'bearer', expires_in: 359999 }, + }); + + const token = await service.refreshToken(datto()); + + expect(token).toBe('datto-at'); + const [url, body, opts] = mockedAxios.post.mock.calls[0] as any; + expect(url).toBe('https://merlot-api.centrastage.net/auth/oauth/token'); + expect(new URLSearchParams(body)).toEqual( + new URLSearchParams('grant_type=password&username=api-key&password=api-secret'), + ); + expect(opts.headers.Authorization).toBe( + 'Basic ' + Buffer.from('public-client:public').toString('base64'), + ); + }); + + it('gets a token on the first call, with nothing stored and no refresh token', async () => { + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'first', expires_in: 359999 }, + }); + + expect(await service.getAccessToken(datto(), 'conn-datto')).toBe('first'); + expect(await service.getAccessToken(datto(), 'conn-datto')).toBe('first'); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('asks for a new token when the 100 hours run out, never with a refresh token', async () => { + const start = Date.now(); + const now = jest.spyOn(Date, 'now').mockReturnValue(start); + try { + mockedAxios.post + .mockResolvedValueOnce({ data: { access_token: 'first', expires_in: 360000 } }) + .mockResolvedValueOnce({ data: { access_token: 'second', expires_in: 360000 } }); + // Left over from the authorization-code flow the connector used before. + const config = datto({ refreshToken: 'stale-rt', accessToken: 'old-at' }); + + expect(await service.getAccessToken(config, 'conn-datto')).toBe('first'); + now.mockReturnValue(start + 100 * 3600 * 1000 - 60 * 1000); + expect(await service.getAccessToken(config, 'conn-datto')).toBe('second'); + + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + for (const call of mockedAxios.post.mock.calls) { + const body = new URLSearchParams((call as any)[1]); + expect(body.get('grant_type')).toBe('password'); + expect(body.get('refresh_token')).toBeNull(); + } + } finally { + now.mockRestore(); + } + }); + + it('gets a new token after a 401', async () => { + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'after-401', expires_in: 360000 }, + }); + + const token = await service.renewAfterRejection(datto(), 'conn-datto', Date.now()); + + expect(token).toBe('after-401'); + const body = new URLSearchParams((mockedAxios.post.mock.calls[0] as any)[1]); + expect(body.get('grant_type')).toBe('password'); + }); + + it('sends the scopes typed in the connector form', async () => { + mockedAxios.post.mockResolvedValue({ data: { access_token: 'at', expires_in: 60 } }); + + await service.refreshToken(datto({ scopes: 'read write' })); + + const body = new URLSearchParams((mockedAxios.post.mock.calls[0] as any)[1]); + expect(body.get('scope')).toBe('read write'); + }); + + it('identifies a public client in the body, with no secret and no Basic header', async () => { + mockedAxios.post.mockResolvedValue({ data: { access_token: 'at', expires_in: 60 } }); + + await service.refreshToken(datto({ clientId: 'my-app', clientSecret: undefined })); + + const [, body, opts] = mockedAxios.post.mock.calls[0] as any; + expect(new URLSearchParams(body).get('client_id')).toBe('my-app'); + expect(new URLSearchParams(body).get('client_secret')).toBeNull(); + expect(opts.headers.Authorization).toBeUndefined(); + }); + + it('sends the client in the body when tokenAuthMethod says so', async () => { + mockedAxios.post.mockResolvedValue({ data: { access_token: 'at', expires_in: 60 } }); + + await service.refreshToken(datto({ tokenAuthMethod: 'client_secret_post' })); + + const [, body, opts] = mockedAxios.post.mock.calls[0] as any; + expect(new URLSearchParams(body).get('client_id')).toBe('public-client'); + expect(new URLSearchParams(body).get('client_secret')).toBe('public'); + expect(opts.headers.Authorization).toBeUndefined(); + }); + + it('does not call the token endpoint without a username and password', async () => { + expect(await service.refreshToken(datto({ password: '' }))).toBeNull(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('says what the token endpoint answered instead of sending an empty bearer', async () => { + mockedAxios.post.mockRejectedValue( + Object.assign(new Error('Request failed with status code 400'), { + response: { + status: 400, + data: { error: 'invalid_grant', error_description: 'Bad credentials' }, + }, + }), + ); + + const call = service.getAccessToken(datto(), 'conn-datto-bad'); + + await expect(call).rejects.toMatchObject({ + status: 401, + message: expect.stringContaining( + 'OAuth2 password: could not obtain an access token from merlot-api.centrastage.net ' + + '(HTTP 400: invalid_grant: Bad credentials)', + ), + }); + await expect(call).rejects.not.toThrow(/api-secret/); + }); + }); + describe('rolling refresh tokens', () => { it('refreshes using the freshest refresh token from the DB, not the stale snapshot', async () => { // DB holds the rotated ("fresh") refresh token; the caller passes a stale diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.ts b/packages/backend/src/connectors/engines/oauth2-token.service.ts index 3bb710498..06bb07655 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.ts @@ -13,6 +13,7 @@ import { } from './client-assertion.util'; import { outboundAxiosOptions } from '../../common/outbound-http'; import { connectorPageUrl } from '../../common/url.util'; +import { isMachineGrant } from '../oauth-grant.util'; /** Refresh tokens that expire within this window (5 minutes). */ const PROACTIVE_REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -93,8 +94,8 @@ export class OAuth2TokenService { } // 2. Determine if proactive refresh is possible and needed. - // client_credentials needs only tokenUrl + clientId/Secret; refresh_token - // also needs a stored refreshToken. + // client_credentials needs only tokenUrl + clientId/Secret; password needs + // tokenUrl + username/password; refresh_token needs a stored refreshToken. const hasRefreshCapability = grant === 'client_credentials' ? !!( @@ -102,7 +103,9 @@ export class OAuth2TokenService { authConfig.clientId && (authConfig.clientSecret || isPrivateKeyJwt(authConfig.tokenAuthMethod)) ) - : !!(authConfig.refreshToken && authConfig.tokenUrl); + : grant === 'password' + ? !!(authConfig.tokenUrl && authConfig.username && authConfig.password) + : !!(authConfig.refreshToken && authConfig.tokenUrl); const tokenNearExpiry = this.isTokenNearExpiry(authConfig, cacheKey); let refreshFailed = false; @@ -125,13 +128,13 @@ export class OAuth2TokenService { } const stored = String(authConfig.accessToken || ''); - // A client_credentials connector has no token but the one it fetches. When - // that fetch fails, sending `Authorization: Bearer ` anyway only trades the - // token endpoint's precise answer for the API's vaguest one: Reddit - // answers an empty bearer with its HTML "Blocked" page, and 52 calls - // failed that way with nobody able to tell a wrong client secret from a - // bot wall. Stop here and say what the token endpoint said. - if (grant === 'client_credentials' && !stored) { + // A client_credentials or password connector has no token but the one it + // fetches. When that fetch fails, sending `Authorization: Bearer ` anyway + // only trades the token endpoint's precise answer for the API's vaguest + // one: Reddit answers an empty bearer with its HTML "Blocked" page, and 52 + // calls failed that way with nobody able to tell a wrong client secret + // from a bot wall. Stop here and say what the token endpoint said. + if (isMachineGrant(grant) && !stored) { const reason = cacheKey ? this.lastRefreshError.get(cacheKey) : undefined; let host = 'the token endpoint'; try { @@ -140,9 +143,12 @@ export class OAuth2TokenService { // keep the generic wording } const err = new Error( - `OAuth2 client_credentials: could not obtain an access token from ${host}` + + `OAuth2 ${grant}: could not obtain an access token from ${host}` + (reason ? ` (${reason})` : '') + - '. No request was sent to the API. Check the client ID and client secret.', + '. No request was sent to the API. ' + + (grant === 'password' + ? 'Check the username and password, and the client ID and client secret if the provider needs them.' + : 'Check the client ID and client secret.'), ) as Error & { status?: number }; // Lets the install-form probe classify this as rejected credentials. err.status = 401; @@ -154,7 +160,7 @@ export class OAuth2TokenService { // looking at the token's format when the refresh token itself had been // refused. Only reached when there is no token at all, so a connector that // has one (stored, or cached) behaves exactly as before. - if (grant !== 'client_credentials' && !stored) { + if (!isMachineGrant(grant) && !stored) { if (refreshFailed) { const reason = cacheKey ? this.lastRefreshError.get(cacheKey) : undefined; throw unauthorized( @@ -209,7 +215,12 @@ export class OAuth2TokenService { const clientSecret = authConfig.clientSecret ? String(authConfig.clientSecret) : undefined; - const scope = authConfig.scope ? String(authConfig.scope) : undefined; + // Adapters write `scope`; the connector form writes `scopes`. + const scope = authConfig.scope + ? String(authConfig.scope) + : authConfig.scopes + ? String(authConfig.scopes) + : undefined; // private_key_jwt: the client proves itself with a JWT signed by its own // key (RFC 7523), so there is no client secret to send or to require. const privateKeyJwt = isPrivateKeyJwt(authConfig.tokenAuthMethod); @@ -231,6 +242,11 @@ export class OAuth2TokenService { ); return null; } + } else if (grant === 'password') { + if (!authConfig.username || !authConfig.password) { + this.logger.warn('OAuth2 password: missing username/password'); + return null; + } } else if (!refreshToken) { this.logger.warn('OAuth2 refresh: missing refreshToken'); return null; @@ -248,8 +264,15 @@ export class OAuth2TokenService { const userAgent = tokenEndpointUserAgent(authConfig, connectorHeaders); if (userAgent) headers['User-Agent'] = userAgent; - if (grant === 'client_credentials') { - body = { grant_type: 'client_credentials' }; + if (isMachineGrant(grant)) { + body = + grant === 'password' + ? { + grant_type: 'password', + username: String(authConfig.username), + password: String(authConfig.password), + } + : { grant_type: 'client_credentials' }; if (scope) body.scope = scope; if (privateKeyJwt) { Object.assign( @@ -263,13 +286,18 @@ export class OAuth2TokenService { // client_secret_post — the other method RFC 6749 §2.3.1 allows, // and the only one some servers document: Amadeus's token // endpoint takes client_id/client_secret as form fields. - body.client_id = String(clientId); - body.client_secret = String(clientSecret); - } else { + if (clientId) body.client_id = String(clientId); + if (clientSecret) body.client_secret = String(clientSecret); + } else if (clientId && clientSecret) { + // Datto RMM's password grant takes its fixed client this way: + // Basic public-client:public. const basic = Buffer.from(`${clientId}:${clientSecret}`).toString( 'base64', ); headers.Authorization = `Basic ${basic}`; + } else if (clientId) { + // A public client on the password grant: identified, no secret. + body.client_id = String(clientId); } } else { body = { @@ -338,8 +366,8 @@ export class OAuth2TokenService { }); // Persist to DB if connectorId is available. For client_credentials - // there's no refresh_token to store — we just record the latest - // access_token + its expiry so a cold-start can reuse it briefly. + // and password there's no refresh_token to use — we just record the + // latest access_token + its expiry so a cold-start can reuse it. if (connectorId) { await this.persistRefreshedToken( connectorId, @@ -399,9 +427,11 @@ export class OAuth2TokenService { if (!reason) return undefined; return unauthorized( `OAuth2: the API refused the access token (401) and it could not be renewed at ` + - `${hostOf(authConfig.tokenUrl)} (${reason}). If the refresh token was refused, ` + - 'authorize the connector again (Authorize with Provider on its page in AnythingMCP) ' + - 'or replace its refresh token.', + `${hostOf(authConfig.tokenUrl)} (${reason}). ` + + (isMachineGrant(authConfig.grant) + ? `Check the credentials the ${String(authConfig.grant)} grant sends to the token URL.` + : 'If the refresh token was refused, authorize the connector again ' + + '(Authorize with Provider on its page in AnythingMCP) or replace its refresh token.'), ); } diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index 3b348ff9b..86ebdfe65 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -1721,6 +1721,35 @@ describe('RestEngine', () => { ); expect(mockedAxios).toHaveBeenCalledTimes(1); }); + + it('gets a new token after a 401 on the password grant, which has no refresh token (Datto RMM)', async () => { + const err = new AxiosError('Unauthorized'); + (err as any).response = { status: 401, data: {} }; + mockedAxios.mockRejectedValueOnce(err).mockResolvedValueOnce({ data: { ok: true } }); + + const result = await engine.execute( + { + ...oauthConfig({ + grant: 'password', + refreshToken: undefined, + username: 'api-key', + password: 'api-secret', + }), + connectorId: 'conn-datto', + }, + { method: 'GET', path: '/api/v2/account' }, + {}, + ); + + expect(result).toEqual({ ok: true }); + expect(mockOAuth2TokenService.renewAfterRejection).toHaveBeenCalledWith( + expect.objectContaining({ grant: 'password' }), + 'conn-datto', + expect.any(Number), + ); + const retried = mockedAxios.mock.calls[1][0] as any; + expect(retried.headers.Authorization).toBe('Bearer new-access-token'); + }); }); describe('form-data file upload (__file)', () => { diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index e6ce874f0..4ee9b816c 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -10,6 +10,7 @@ import FormData from 'form-data'; import { createUnblockerProxyAgent } from './unblocker-proxy-agent'; import { buildOAuth1Header } from './oauth1-signer'; import { OAuth2TokenService } from './oauth2-token.service'; +import { canRenewToken } from '../oauth-grant.util'; import { LoginTokenService, LoginTokenAuthConfig, @@ -484,13 +485,13 @@ export class RestEngine { const response = await this.requestWithRetry(axiosConfig, outbound); return withMeta(response); } catch (error) { - // OAuth2 auto-refresh: retry once on 401 + // OAuth2 auto-refresh: retry once on 401 (a refresh token, or a grant + // that asks for a new token by itself, such as Datto's password grant) if ( error instanceof AxiosError && error.response?.status === 401 && config.authType === 'OAUTH2' && - config.authConfig?.refreshToken && - config.authConfig?.tokenUrl + canRenewToken(config.authConfig) ) { this.logger.debug('OAuth2: access token expired, attempting refresh...'); const newToken = await this.oauth2TokenService.renewAfterRejection( diff --git a/packages/backend/src/connectors/oauth-grant.util.spec.ts b/packages/backend/src/connectors/oauth-grant.util.spec.ts new file mode 100644 index 000000000..cf9ca0d08 --- /dev/null +++ b/packages/backend/src/connectors/oauth-grant.util.spec.ts @@ -0,0 +1,21 @@ +import { canRenewToken, isMachineGrant } from './oauth-grant.util'; + +describe('oauth-grant.util', () => { + it('treats client_credentials and password as grants that need no user', () => { + expect(isMachineGrant('client_credentials')).toBe(true); + expect(isMachineGrant('password')).toBe(true); + expect(isMachineGrant(undefined)).toBe(false); + expect(isMachineGrant('refresh_token')).toBe(false); + expect(isMachineGrant('authorization_code')).toBe(false); + }); + + it('renews after a 401 with a refresh token or a machine grant, never without a token URL', () => { + const tokenUrl = 'https://example.com/token'; + expect(canRenewToken({ tokenUrl, refreshToken: 'rt' })).toBe(true); + expect(canRenewToken({ tokenUrl, grant: 'password' })).toBe(true); + expect(canRenewToken({ tokenUrl, grant: 'client_credentials' })).toBe(true); + expect(canRenewToken({ tokenUrl })).toBe(false); + expect(canRenewToken({ grant: 'password' })).toBe(false); + expect(canRenewToken(undefined)).toBe(false); + }); +}); diff --git a/packages/backend/src/connectors/oauth-grant.util.ts b/packages/backend/src/connectors/oauth-grant.util.ts new file mode 100644 index 000000000..02d6bda4b --- /dev/null +++ b/packages/backend/src/connectors/oauth-grant.util.ts @@ -0,0 +1,21 @@ +/** + * Grants that obtain a token from stored credentials alone, with no user at + * the provider: `client_credentials` (the client's own id and secret) and + * `password` (RFC 6749 §4.3, a username and password; Datto RMM issues its + * 100-hour tokens this way, with no refresh token). A new token is simply + * requested again when the old one expires or is refused. + */ +export function isMachineGrant(grant: unknown): boolean { + return grant === 'client_credentials' || grant === 'password'; +} + +/** + * Whether a token refused by the API (401) can be renewed without the user: + * a stored refresh token, or a grant that asks for a new token by itself. + */ +export function canRenewToken( + authConfig: Record | undefined, +): authConfig is Record { + if (!authConfig?.tokenUrl) return false; + return !!authConfig.refreshToken || isMachineGrant(authConfig.grant); +} diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index 2af37a1af..0b0ba2883 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; -import { connectors, tools, withSecretsNotice, type ToolTestResult, type CatalogDiff } from '@/lib/api'; +import { connectors, tools, withSecretsNotice, isMachineGrant, type ToolTestResult, type CatalogDiff, type OAuthGrant } from '@/lib/api'; import { findDemoByTool } from '@/lib/demo-connectors'; import { ToolEditor } from '@/components/tool-editor'; import { McpAssignModal } from '@/components/mcp-assign-modal'; @@ -91,6 +91,16 @@ export default function ConnectorDetailPage() { const [editOauthAuthUrl, setEditOauthAuthUrl] = useState(''); const [editOauthTokenUrl, setEditOauthTokenUrl] = useState(''); const [editOauthScopes, setEditOauthScopes] = useState(''); + // OAuth2 grant. `loadedOauthGrant` is what is stored: it decides whether the + // connector shows "Authorize with Provider" (a machine grant needs no + // sign-in), and saving sends the grant only when the user changed it. + const [editOauthGrant, setEditOauthGrant] = useState('authorization_code'); + const [loadedOauthGrant, setLoadedOauthGrant] = useState('authorization_code'); + // Password grant credentials (Datto RMM: API key and API secret). Never + // pre-filled; an empty field keeps the stored value. + const [editOauthUsername, setEditOauthUsername] = useState(''); + const [editOauthPassword, setEditOauthPassword] = useState(''); + const [oauthHasPasswordCreds, setOauthHasPasswordCreds] = useState(false); // OAuth 1.0a credentials. Never pre-filled (the server does not send them // back); an empty field keeps the stored value. const [editOauth1Key, setEditOauth1Key] = useState(''); @@ -191,6 +201,8 @@ export default function ConnectorDetailPage() { setEditAuthValue(''); setEditWsPasswordType('PasswordText'); resetOauth1Fields(); + setEditOauthUsername(''); + setEditOauthPassword(''); // The auth method is not secret, so it can be shown. Without this the // select would always read "body" and saving any other field would // silently reset a connector configured for HTTP Basic. @@ -204,6 +216,9 @@ export default function ConnectorDetailPage() { setEditOauthAuthUrl(r.authorizationUrl || ''); setEditOauthTokenUrl(r.tokenUrl || ''); setEditOauthScopes(r.scopes || ''); + setEditOauthGrant(r.grant || 'authorization_code'); + setLoadedOauthGrant(r.grant || 'authorization_code'); + setOauthHasPasswordCreds(r.hasUsername && r.hasPassword); }) .catch(() => setEditTokenAuthMethod('client_secret_post')); } @@ -356,6 +371,14 @@ export default function ConnectorDetailPage() { const handleSave = async () => { if (!token) return; try { + if ( + editAuthType === 'OAUTH2' && + editOauthGrant === 'password' && + !oauthHasPasswordCreds && + (!editOauthUsername.trim() || !editOauthPassword) + ) { + throw new Error('Enter the username and password the password grant sends to the token URL.'); + } const data: Record = { name: editName, baseUrl: editBaseUrl, @@ -428,6 +451,11 @@ export default function ConnectorDetailPage() { }; if (editAuthKey) oauthPatch.clientId = editAuthKey; if (editAuthValue) oauthPatch.clientSecret = editAuthValue; + if (editOauthGrant !== loadedOauthGrant) oauthPatch.grant = editOauthGrant; + if (editOauthGrant === 'password') { + if (editOauthUsername.trim()) oauthPatch.username = editOauthUsername.trim(); + if (editOauthPassword) oauthPatch.password = editOauthPassword; + } // Endpoints are not secret, so the form always holds their current // value and can round-trip them. oauthPatch.authorizationUrl = editOauthAuthUrl.trim(); @@ -1252,6 +1280,38 @@ export default function ConnectorDetailPage() { )} {editAuthType === 'OAUTH2' && connector.type !== 'MCP' && (
+
+ + +

+ {editOauthGrant === 'authorization_code' + ? 'Authorize with Provider once; the refresh token renews the access token.' + : editOauthGrant === 'password' + ? 'For APIs that issue tokens with no refresh token: a new one is requested with the username and password whenever it expires. Datto RMM: username = API key, password = API secret, client public-client / public with HTTP Basic.' + : 'The client ID and secret alone get the token, and a new one whenever it expires. No sign-in at the provider.'} +

+
+ {editOauthGrant === 'password' && ( +
+
+ + setEditOauthUsername(e.target.value)} placeholder={oauthHasPasswordCreds ? 'Leave empty to keep current' : ''} className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> +
+
+ + setEditOauthPassword(e.target.value)} placeholder={oauthHasPasswordCreds ? 'Leave empty to keep current' : ''} className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> +
+
+ )}
@@ -1262,10 +1322,12 @@ export default function ConnectorDetailPage() { setEditAuthValue(e.target.value)} placeholder="Leave empty to keep current" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" />
+ {!isMachineGrant(editOauthGrant) && (
setEditOauthAuthUrl(e.target.value)} placeholder="https://provider.com/oauth/authorize" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)] font-mono text-[13px]" />
+ )}
setEditOauthTokenUrl(e.target.value)} placeholder="https://provider.com/oauth/token" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)] font-mono text-[13px]" /> @@ -1290,9 +1352,12 @@ export default function ConnectorDetailPage() {

{editTokenAuthMethod === 'private_key_jwt' ? 'Set by the connector: a short-lived JWT signed with the private key from the environment variables replaces the client secret.' - : 'Switch to HTTP Basic if the token exchange fails with 401 — Datto RMM and DATEV require it. Applies to refreshes too. Re-authorize after changing it.'} + : isMachineGrant(editOauthGrant) + ? 'How the client ID and secret reach the token URL. HTTP Basic is the default for this grant.' + : 'Switch to HTTP Basic if the token exchange fails with 401 — Datto RMM and DATEV require it. Applies to refreshes too. Re-authorize after changing it.'}

+ {!isMachineGrant(editOauthGrant) && (
+ )}

Leave Client ID / Client Secret empty to keep the stored values. Endpoints are shown as configured and can be corrected here — useful when a connector @@ -1471,7 +1537,7 @@ export default function ConnectorDetailPage() { {/* OAuth2 Authorization */} - {connector.authType === 'OAUTH2' && ( + {connector.authType === 'OAUTH2' && (connector.type === 'MCP' || !isMachineGrant(loadedOauthGrant)) && (

OAuth2 Authorization

diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index 34a43524b..92731c0af 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; -import { connectors } from '@/lib/api'; +import { connectors, isMachineGrant, type OAuthGrant } from '@/lib/api'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { McpAssignModal } from '@/components/mcp-assign-modal'; @@ -76,6 +76,11 @@ export default function NewConnectorPage() { // How client credentials reach the token endpoint. Some providers (Datto RMM, // DATEV) reject body-supplied credentials and require an HTTP Basic header. const [oauthTokenAuthMethod, setOauthTokenAuthMethod] = useState('client_secret_post'); + // How the connector gets its tokens: a sign-in at the provider, or a grant + // that asks the token URL by itself (Datto RMM: password grant). + const [oauthGrant, setOauthGrant] = useState('authorization_code'); + const [oauthUsername, setOauthUsername] = useState(''); + const [oauthPassword, setOauthPassword] = useState(''); // LOGIN_TOKEN (credentials → short-lived token, auto-refreshed) fields const [ltLoginUrl, setLtLoginUrl] = useState(''); const [ltMethod, setLtMethod] = useState('POST'); @@ -131,6 +136,20 @@ export default function NewConnectorPage() { } case 'OAUTH2': if (selectedType !== 'MCP') { + if (isMachineGrant(oauthGrant)) { + return { + grant: oauthGrant, + clientId: oauthClientId || undefined, + clientSecret: oauthClientSecret || undefined, + ...(oauthGrant === 'password' + ? { username: oauthUsername, password: oauthPassword } + : {}), + tokenUrl: oauthTokenUrl, + scopes: oauthScopes || undefined, + // Basic is these grants' default, so "in body" is stored as such. + tokenAuthMethod: oauthTokenAuthMethod, + }; + } return { clientId: oauthClientId, clientSecret: oauthClientSecret || undefined, @@ -163,6 +182,15 @@ export default function NewConnectorPage() { e.preventDefault(); if (!token || !selectedType) return; setError(''); + if ( + authType === 'OAUTH2' && + selectedType !== 'MCP' && + oauthGrant === 'password' && + (!oauthUsername.trim() || !oauthPassword) + ) { + setError('Enter the username and password the password grant sends to the token URL.'); + return; + } setLoading(true); try { @@ -554,6 +582,43 @@ export default function NewConnectorPage() { {authType === 'OAUTH2' && selectedType !== 'MCP' && (

+
+ + + {oauthGrant === 'password' && ( +

+ For APIs that issue tokens with no refresh token: a new one is requested with the + username and password whenever it expires. Datto RMM: username = API key, + password = API secret, client ID public-client, secret public, HTTP Basic. +

+ )} +
+ {oauthGrant === 'password' && ( +
+
+ + setOauthUsername(e.target.value)} className={cn(inputClass, 'font-mono text-[13px]')} /> +
+
+ + setOauthPassword(e.target.value)} className={cn(inputClass, 'font-mono text-[13px]')} /> +
+
+ )}
@@ -564,10 +629,12 @@ export default function NewConnectorPage() { setOauthClientSecret(e.target.value)} placeholder="optional" className={cn(inputClass, 'font-mono text-[13px]')} />
-
- - setOauthAuthUrl(e.target.value)} placeholder="https://provider.com/oauth/authorize" className={cn(inputClass, 'font-mono text-[13px]')} /> -
+ {!isMachineGrant(oauthGrant) && ( +
+ + setOauthAuthUrl(e.target.value)} placeholder="https://provider.com/oauth/authorize" className={cn(inputClass, 'font-mono text-[13px]')} /> +
+ )}
setOauthTokenUrl(e.target.value)} placeholder="https://provider.com/oauth/token" className={cn(inputClass, 'font-mono text-[13px]')} /> @@ -583,20 +650,27 @@ export default function NewConnectorPage() { onChange={(e) => setOauthTokenAuthMethod(e.target.value)} className={inputClass} > - - + +

- Switch to HTTP Basic if the provider returns 401 when exchanging the - code — Datto RMM and DATEV both require it. Applies to token refreshes too. + {isMachineGrant(oauthGrant) + ? 'How the client ID and secret reach the token URL.' + : 'Switch to HTTP Basic if the provider returns 401 when exchanging the code — DATEV requires it. Applies to token refreshes too.'}

+ {isMachineGrant(oauthGrant) ? ( +
+

No sign-in needed: the connector requests its access token from the token URL, and a new one whenever it expires.

+
+ ) : (

After creating the connector, you will be redirected to authorize via OAuth2. Tokens will be stored securely.

Set the Redirect / Callback URI in your OAuth provider to: {oauthRedirectUri ?? '…'}

+ )}
)} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index e6a16a1ac..02287df93 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -283,6 +283,19 @@ export const mcpConnections = { // Connectors +/** + * How an OAuth2 connector gets its access token. `authorization_code` signs in + * at the provider ("Authorize with Provider"); `client_credentials` and + * `password` ask the token endpoint with stored credentials, again whenever + * the token expires. + */ +export type OAuthGrant = 'authorization_code' | 'client_credentials' | 'password'; + +/** True for the grants that need no sign-in at the provider. */ +export function isMachineGrant(grant: string | undefined): boolean { + return grant === 'client_credentials' || grant === 'password'; +} + export interface CatalogBaseUrlChange { from: string; to: string; @@ -371,6 +384,8 @@ export const connectors = { getOAuthConfig: (id: string, token: string) => request<{ clientId: string; + /** How tokens are obtained: a sign-in at the provider, or a machine grant. */ + grant: OAuthGrant; authorizationUrl: string; tokenUrl: string; scopes: string; @@ -378,6 +393,8 @@ export const connectors = { /** Whether "Authorize with Provider" sends PKCE (on unless turned off). */ pkce: boolean; hasClientSecret: boolean; + hasUsername: boolean; + hasPassword: boolean; hasAccessToken: boolean; hasRefreshToken: boolean; }>(`/api/connectors/${id}/oauth-config`, { token }), @@ -390,6 +407,9 @@ export const connectors = { data: { clientId?: string; clientSecret?: string; + grant?: OAuthGrant; + username?: string; + password?: string; authorizationUrl?: string; tokenUrl?: string; scopes?: string; diff --git a/packages/frontend/tests/e2e/connector-oauth-grant.spec.ts b/packages/frontend/tests/e2e/connector-oauth-grant.spec.ts new file mode 100644 index 000000000..78742edaf --- /dev/null +++ b/packages/frontend/tests/e2e/connector-oauth-grant.spec.ts @@ -0,0 +1,139 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * The OAuth2 editor offers the grant. Datto RMM issues 100-hour tokens with + * no refresh token, so a connector set up with the authorization-code flow + * stopped working after 100 hours; with the password grant it asks the token + * URL again by itself, and there is nothing to authorize. + */ + +const USER = { + id: 'u1', + email: 'test@example.com', + name: 'Test User', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +const CONNECTOR = { + id: 'c1', + name: 'Datto RMM', + type: 'REST', + baseUrl: 'https://merlot-api.centrastage.net', + authType: 'OAUTH2', + authConfig: 'opaque-ciphertext', + isActive: true, + headers: null, + envVars: null, + config: null, + tools: [], + createdAt: '2026-09-01T00:00:00Z', + userId: 'u1', + organizationId: 'o1', +}; + +const OAUTH_CONFIG = { + clientId: 'public-client', + authorizationUrl: 'https://merlot-api.centrastage.net/auth/oauth/authorize', + tokenUrl: 'https://merlot-api.centrastage.net/auth/oauth/token', + scopes: '', + tokenAuthMethod: 'client_secret_basic', + pkce: true, + hasClientSecret: true, + hasAccessToken: true, + hasRefreshToken: false, +}; + +async function openConnector(page: Page, oauthConfig: Record) { + const patches: unknown[] = []; + await page.context().addCookies([ + { name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }, + ]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); + + await page.route(/\/api\//, async (route) => { + const req = route.request(); + const url = req.url(); + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + if (url.includes('/api/connectors/c1/oauth-config')) { + if (req.method() === 'PATCH') { + patches.push(req.postDataJSON()); + return json({ message: 'OAuth configuration updated' }); + } + return json(oauthConfig); + } + if (url.includes('/api/connectors/c1') && req.method() === 'PUT') return json(CONNECTOR); + if (url.includes('/api/connectors/proxy-availability')) return json({ available: false }); + if (url.includes('/api/connectors/c1/catalog-diff')) return json({ catalogManaged: false }); + if (url.includes('/api/connectors/c1')) return json(CONNECTOR); + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (url.includes('/api/organizations/mine')) return json([{ id: 'o1', name: 'Acme', role: 'ADMIN', joinedAt: '2026-01-01' }]); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (url.includes('/api/connectors')) return json([CONNECTOR]); + return json({}); + }); + + await page.goto('/connectors/c1'); + return { patches }; +} + +test.describe('Connector editor — OAuth2 grant', () => { + test('switches an authorization-code connector to the password grant', async ({ page }) => { + const { patches } = await openConnector(page, OAUTH_CONFIG); + await expect(page.getByRole('button', { name: 'Authorize with Provider' })).toBeVisible(); + + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await page.getByLabel('Grant type').selectOption('password'); + // Nothing to authorize with this grant. + await expect(page.getByText('Authorization URL', { exact: true })).toHaveCount(0); + + await page.getByRole('button', { name: 'Save Changes' }).click(); + await expect(page.getByText(/Enter the username and password/)).toBeVisible(); + expect(patches).toEqual([]); + + await page.getByLabel('Username').fill('datto-api-key'); + await page.getByLabel('Password', { exact: true }).fill('datto-api-secret'); + await page.getByRole('button', { name: 'Save Changes' }).click(); + + await expect(page.getByText('Connector updated')).toBeVisible(); + expect(patches).toHaveLength(1); + expect(patches[0]).toMatchObject({ + grant: 'password', + username: 'datto-api-key', + password: 'datto-api-secret', + tokenAuthMethod: 'client_secret_basic', + }); + }); + + test('hides Authorize with Provider for a password-grant connector and keeps its credentials', async ({ page }) => { + const { patches } = await openConnector(page, { + ...OAUTH_CONFIG, + grant: 'password', + hasUsername: true, + hasPassword: true, + }); + await expect(page.getByRole('button', { name: 'Edit', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Authorize with Provider' })).toHaveCount(0); + + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await expect(page.getByLabel('Grant type')).toHaveValue('password'); + await expect(page.getByLabel('Username')).toHaveAttribute('placeholder', 'Leave empty to keep current'); + + await page.getByRole('button', { name: 'Save Changes' }).click(); + + await expect(page.getByText('Connector updated')).toBeVisible(); + expect(patches).toHaveLength(1); + // Unchanged grant and untouched credentials are not sent. + expect(patches[0]).not.toHaveProperty('grant'); + expect(patches[0]).not.toHaveProperty('username'); + expect(patches[0]).not.toHaveProperty('password'); + }); +});