diff --git a/docs/connectors/rest.md b/docs/connectors/rest.md index 6e94b00e..37b07375 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 a9c1b626..492aee1b 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 40b8c89f..25b76a7a 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 c1ef2664..41633a3d 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 ffb7eea2..b22c932b 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 3bb71049..06bb0765 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 3b348ff9..86ebdfe6 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 e6ce874f..4ee9b816 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 00000000..cf9ca0d0 --- /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 00000000..02d6bda4 --- /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 2af37a1a..0b0ba288 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 34a43524..92731c0a 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 e6a16a1a..02287df9 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 00000000..78742eda --- /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'); + }); +});