Skip to content

Commit c70981a

Browse files
committed
fix(credentials): close round-1 CI and review findings on alt auth
- Knowledge connector tests expect the service now passed with Google service-account token requests; a new test proves a JSON-key mint signs the same request with or without it. - Re-record the tool-registry-boundary baseline: the keyless Snowflake, Microsoft app, and Google modules add 5 modules behind credential-service, which tipped credit-usage past its allowance on top of staging's drift. - Docs compatibility baseline: record the new code examples in the Google and Snowflake service account pages. - Generate the Microsoft certificate test fixture at run time instead of committing a PEM key and certificate. - A Microsoft app credential reconnect opens on keyless only when the stored credential is federated (the prepare step now reports it), like Snowflake; new connections still default to keyless. - TSDoc on the Google project number, pool/provider ID, and service account email validators.
1 parent b274f9d commit c70981a

11 files changed

Lines changed: 451 additions & 320 deletions

File tree

‎apps/docs/scripts/fixtures/content-baseline.json‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67021,7 +67021,15 @@
6702167021
"line": 122
6702267022
}
6702367023
],
67024-
"codeFences": [],
67024+
"codeFences": [
67025+
{
67026+
"lang": "bash",
67027+
"meta": null,
67028+
"sha256": "b867716c5b178cc345e9ec091ffd5db588c778b02f244e3e51513c9266fbaf8a",
67029+
"bytes": 733,
67030+
"line": 199
67031+
}
67032+
],
6702567033
"links": [
6702667034
"https://console.cloud.google.com/",
6702767035
"#scopes-reference",
@@ -141488,12 +141496,19 @@
141488141496
],
141489141497
"expressions": [],
141490141498
"codeFences": [
141499+
{
141500+
"lang": "sql",
141501+
"meta": null,
141502+
"sha256": "96716701cef0ce8a3205217105b932f669acc115ec2faed939bf81704eb83ed2",
141503+
"bytes": 169,
141504+
"line": 29
141505+
},
141491141506
{
141492141507
"lang": "sql",
141493141508
"meta": null,
141494141509
"sha256": "0fbd73d76ccb6a73e3f2f817a592bff15a0d1ffb45b4425fbfc91cc7ac0a48da",
141495141510
"bytes": 134,
141496-
"line": 46
141511+
"line": 94
141497141512
}
141498141513
],
141499141514
"links": [

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/microsoft-app-account-modal.tsx‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,16 @@ function MicrosoftAppAccountForm({
8989
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
9090
const [description, setDescription] = useState(initialDescription ?? '')
9191

92-
// Keyless is the default wherever this deployment supports it.
93-
const authType: MicrosoftAuthType =
94-
selectedAuthType ?? (keyless.available ? 'federated' : 'client_secret')
92+
// A reconnect opens on keyless only for a keyless credential, which has a subject to keep; a
93+
// new connection defaults to keyless wherever this deployment supports it.
94+
const defaultAuthType: MicrosoftAuthType = credentialId
95+
? keyless.reconnectsKeyless
96+
? 'federated'
97+
: 'client_secret'
98+
: keyless.available
99+
? 'federated'
100+
: 'client_secret'
101+
const authType: MicrosoftAuthType = selectedAuthType ?? defaultAuthType
95102
const disabled =
96103
isPending ||
97104
!tenantId.trim() ||

‎apps/sim/lib/api/contracts/credentials.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,7 @@ export const prepareWorkloadIdentityContract = defineRouteContract({
583583
/** Opaque proof submitted with the credential; binds the subject to its creator. */
584584
subjectProof: z.string(),
585585
expiresAt: z.string(),
586-
/** The reconnected credential is keyless (Snowflake and Google only). */
586+
/** The reconnected credential is keyless (Snowflake, Microsoft app, and Google only). */
587587
reconnectsKeyless: z.literal(true).optional(),
588588
}),
589589
]),

‎apps/sim/lib/credentials/application/prepare-workload-identity.test.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ function reconnecting(
5959
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(secret) })
6060
}
6161

62-
function prepare(provider: 'snowflake' | 'google' = 'snowflake') {
62+
function prepare(provider: 'snowflake' | 'google' | 'microsoft' = 'snowflake') {
6363
return prepareWorkloadIdentity.execute({
6464
principal,
6565
input: { workspaceId: 'workspace-1', provider, credentialId: 'cred-1' },
@@ -154,3 +154,47 @@ describe('preparing a Google service account reconnect', () => {
154154
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
155155
})
156156
})
157+
158+
describe('preparing a Microsoft app credential reconnect', () => {
159+
const microsoft = { providerId: 'microsoft-app-service-account' }
160+
const appConfig = {
161+
tenantId: '11111111-2222-3333-4444-555555555555',
162+
clientId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
163+
}
164+
165+
beforeEach(() => {
166+
workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({
167+
workspaceId: 'workspace-1',
168+
workspaceOrganizationId: null,
169+
allowPersonalApiKeys: true,
170+
billedAccountUserId: 'user-1',
171+
})
172+
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('write')
173+
})
174+
175+
it('opens a federated credential on keyless', async () => {
176+
reconnecting(
177+
{
178+
type: 'microsoft_app_credential_v1',
179+
config: {
180+
...appConfig,
181+
authType: 'federated',
182+
subject: 'sim:workspace:workspace-1:credential:abcdef0123456789',
183+
},
184+
},
185+
microsoft
186+
)
187+
await expect(prepare('microsoft')).resolves.toMatchObject({ reconnectsKeyless: true })
188+
})
189+
190+
it.each([
191+
{ authType: 'client_secret', clientSecret: 'value' },
192+
{ authType: 'certificate', privateKey: 'key', certificate: 'cert' },
193+
])('opens a $authType credential on its keyed mode', async (mode) => {
194+
reconnecting(
195+
{ type: 'microsoft_app_credential_v1', config: { ...appConfig, ...mode } },
196+
microsoft
197+
)
198+
expect(await prepare('microsoft')).not.toHaveProperty('reconnectsKeyless')
199+
})
200+
})

‎apps/sim/lib/credentials/application/prepare-workload-identity.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { decryptSecret } from '@/lib/core/security/encryption'
55
import { getCredentialActorContext } from '@/lib/credentials/access'
66
import { credentialOperations } from '@/lib/credentials/application/operations'
77
import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity'
8+
import { isKeylessMicrosoftAppSecret } from '@/lib/credentials/microsoft-app-service-account.server'
89
import { isKeylessSnowflakeSecret } from '@/lib/credentials/snowflake-service-account.server'
910
import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
1011
import {
@@ -16,7 +17,10 @@ import {
1617
isWorkloadIdentityConfigured,
1718
} from '@/lib/credentials/workload-identity/issuer'
1819
import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject'
19-
import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
20+
import {
21+
GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
22+
MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID,
23+
} from '@/lib/oauth/types'
2024
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context'
2125

2226
export interface PrepareWorkloadIdentityInput {
@@ -51,14 +55,18 @@ const MIXED_MODE_PROVIDERS: Partial<
5155
providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
5256
isKeyless: isKeylessSnowflakeSecret,
5357
},
58+
microsoft: {
59+
providerId: MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID,
60+
isKeyless: isKeylessMicrosoftAppSecret,
61+
},
5462
google: {
5563
providerId: GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID,
5664
isKeyless: isGoogleWorkloadIdentitySecret,
5765
},
5866
}
5967

6068
/**
61-
* Whether a Snowflake or Google credential in this workspace, which this person
69+
* Whether a Snowflake, Microsoft app, or Google credential in this workspace, which this person
6270
* administers, is keyless. Only that one bit leaves the stored secret, and only
6371
* for someone allowed to reconnect the credential.
6472
*/

‎apps/sim/lib/credentials/google-workload-identity.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,27 @@ const POOL_OR_PROVIDER_ID_PATTERN = /^(?!gcp-)[a-z0-9-]{4,32}$/
5656
const SERVICE_ACCOUNT_EMAIL_PATTERN =
5757
/^[a-z][-a-z0-9]{4,28}[a-z0-9]@[a-z][-a-z0-9]{4,28}[a-z0-9]\.iam\.gserviceaccount\.com$/
5858

59+
/**
60+
* A Google Cloud project number: digits only, no leading zero, up to 20 digits
61+
* (`123456789012`). The project ID (`my-project`) is not accepted.
62+
*/
5963
export function isGoogleProjectNumber(value: string): boolean {
6064
return PROJECT_NUMBER_PATTERN.test(value)
6165
}
6266

67+
/**
68+
* A workload identity pool or provider ID: 4-32 lowercase letters, digits, or
69+
* hyphens, not starting with the reserved `gcp-` prefix.
70+
*/
6371
export function isGoogleWorkloadIdentityId(value: string): boolean {
6472
return POOL_OR_PROVIDER_ID_PATTERN.test(value)
6573
}
6674

75+
/**
76+
* A user-managed service account email, `<account-id>@<project-id>.iam.gserviceaccount.com`,
77+
* where both IDs are 6-30 characters of lowercase letters, digits, and hyphens,
78+
* start with a letter, and end with a letter or digit.
79+
*/
6780
export function isGoogleServiceAccountEmail(value: string): boolean {
6881
return SERVICE_ACCOUNT_EMAIL_PATTERN.test(value)
6982
}

‎apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts‎

Lines changed: 71 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22
* @vitest-environment node
33
*/
44

5-
import { createHash, createPublicKey, generateKeyPairSync, X509Certificate } from 'node:crypto'
5+
import {
6+
createHash,
7+
createPublicKey,
8+
createSign,
9+
generateKeyPairSync,
10+
X509Certificate,
11+
} from 'node:crypto'
612
import { queueTableRows, resetDbChainMock } from '@sim/testing'
713
import { jsonResponse } from '@sim/testing/helpers/http'
814
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
@@ -24,57 +30,70 @@ import {
2430
import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject'
2531
import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
2632

27-
/** A throwaway self-signed test certificate (CN=sim-test-app) and its key; not used anywhere real. */
28-
const TEST_CERTIFICATE = [
29-
'-----BEGIN CERTIFICATE-----',
30-
'MIIC8DCCAdigAwIBAgIUQscAay/pQghX1nRRknLCHHuel/EwDQYJKoZIhvcNAQEL',
31-
'BQAwFzEVMBMGA1UEAwwMc2ltLXRlc3QtYXBwMCAXDTI2MTExMDIzNDY1NVoYDzIx',
32-
'MjYxMDE3MjM0NjU1WjAXMRUwEwYDVQQDDAxzaW0tdGVzdC1hcHAwggEiMA0GCSqG',
33-
'SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCWiZIqbXoaCMuDVJK8lAWogeXpcfbmlCAm',
34-
'MnPJNSz751gOsfbHMOBLRaQp5fPqjqNodFw72R4Dpa4aETco5l4JlLUrRToK+y4m',
35-
'BCDMN6vwyS+4GTJmO2nX1yvEEhYtHphqg/htnVxRoSzB08G0Bb/64CHe0X+DWs6M',
36-
'CHtfyC9YTG+oc8S8N6Cw1BKV4o+v1C0yEDTjK77qS6YdulAVMjBfhz1cIBLzU/Uy',
37-
'0Fzm7TAE/9h6gHODYxLW2POJeLPQ7uZBzT6uBDL4wYDWrk1iUK2TuTO+6GouKvoU',
38-
'tYv2N4i7KKXur5wpbxDlNWUzBp0B4ozgR0K83Qteq02RShAoiyElAgMBAAGjMjAw',
39-
'MB0GA1UdDgQWBBTVLn0q75C2SHJFPqk4VfXcvH9YWjAPBgNVHRMBAf8EBTADAQH/',
40-
'MA0GCSqGSIb3DQEBCwUAA4IBAQBw3E87CJJs23t9GKlsh35nuVoHzXyJQXwjn32G',
41-
'B6W87h58n/RnrBRRbCzwrGjXHlu6XsT2pwlBKdEezikaajmJhmYAVOSPbvHalnXt',
42-
'TqpY9uvRl/RLnyEe36Y+BrYlr2MoWdsR8etj4aepBzJ8gxq6/tpHiq8ijqR2peCV',
43-
'QZ/9UuoT8fub9DDxMMrwMmGT8hHGOvTh6342yl6EQkKLgV0klUDtfJLA5wPiitTZ',
44-
'pdq7nWlDBGpX5MF1WumpW4CM30s7VAi+HuJZSI1AGg0akTfvcyirDUS/GtUoTwq0',
45-
'/l8LVPe6TsEitCqMpetndCmQ1Y/2TN56MV5Ce1YSkqtYZxi3',
46-
'-----END CERTIFICATE-----',
47-
].join('\n')
48-
const TEST_PRIVATE_KEY = [
49-
'-----BEGIN PRIVATE KEY-----',
50-
'MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCWiZIqbXoaCMuD',
51-
'VJK8lAWogeXpcfbmlCAmMnPJNSz751gOsfbHMOBLRaQp5fPqjqNodFw72R4Dpa4a',
52-
'ETco5l4JlLUrRToK+y4mBCDMN6vwyS+4GTJmO2nX1yvEEhYtHphqg/htnVxRoSzB',
53-
'08G0Bb/64CHe0X+DWs6MCHtfyC9YTG+oc8S8N6Cw1BKV4o+v1C0yEDTjK77qS6Yd',
54-
'ulAVMjBfhz1cIBLzU/Uy0Fzm7TAE/9h6gHODYxLW2POJeLPQ7uZBzT6uBDL4wYDW',
55-
'rk1iUK2TuTO+6GouKvoUtYv2N4i7KKXur5wpbxDlNWUzBp0B4ozgR0K83Qteq02R',
56-
'ShAoiyElAgMBAAECggEASV7H1NIC/wlmV54y3qKul1CohD09f2iWusdAU8MNJ8G7',
57-
'0329r5wM1eMdfiZL2fingFOmWK0byd9Imf/mCJMvJjBKlMpq3ctK1NCSQ7amoPcN',
58-
'wQbW36j5j2+aLzg5gsg0tUWZIaPeyRJrRBU3hcx/o4D2QSmOorDxlqLTrv1cxgXL',
59-
'SlnXPZqAHG6oJl8xLjWnb57ju3Y/qCGLCKAEkiCwVXosEOtxpTZmQc5a7SVVoFI0',
60-
'oZCZyL8vhp96tKPg/Lm3BmontxS+B58QCfMRJs+6SXIZzUa07NjlHjz8PnXVqzVK',
61-
'pf510iOW+yO+rtBb0CA6oHai5MN39c4xNxR0mvFryQKBgQDTc6Wr6pQFQ9NN/INf',
62-
'sJIjRrF6wy2iIscjyUjymyhsKxAFtxs0iVVhZUqxCpfPLH80S6qYodVDf+S9KSN9',
63-
'EI1Yjda4Py9MWtfWYB5smPMprcqQS/UsM0tsRZImmT7r1cES5vVUiN5SQrSqV5cb',
64-
'GbhHfSRnYiwzDci81SHXBE4iewKBgQC2QJfn70t/amJVB1YeXrRL8urHL5KxWYAn',
65-
'6JtiPHXg8ZajUav/zBugUeKaFqUmEedlXe9UlDChf4PzRxk0PwFE3a1Z+XaX0KsC',
66-
'0rx6ZNsofbllX51z+CpxI930SFtuUsd2FmKmg6+PPz7BeQJDsbrpQ0Khr8ELaAed',
67-
'ydk8DDHI3wKBgQCiudYeZmlBYAAGfTSQ+G6ObUH6SqgcpY0BUk8sPRGvpLgw3cg2',
68-
'9xVY3b3MX22SxxihozWHPsvDpXHfzP/9qDZOluFOa3m31hVSaigfxNklExTXVU05',
69-
'lcQOb7/YqoiMr7RlGo5kXuxQ+/rTOJMWDQYPJhRU521CYPJBt/fR5ANJbwKBgQCk',
70-
'IcXsRdBbPmayJrpC/Y0n/Ou9WLWmbHEC//O347VRUKEWEj5xTtLubfBCTvSKNYOM',
71-
'Aa3BIPgu6VTe6f/j0n4FFBLDPeu0y2LHVxwQqfXJIHivuA1n3G30mJWSIvvcfkZN',
72-
'rdlIFr4rpoyQ1rbYWh53ZnCChnK3IAgD7T1rW5wWRwKBgQC2z0nf+2BAGkTYJcIr',
73-
'7L36kA9k5jno2jWyYmtRmQJr3IN7BFLexor6n4Q/nLIrZrstOgq84Sh3rAhTTh+i',
74-
'k4YG+ftqbkRwp7RKDpJ3s7mAC0qgNNKhnZAbDEDpmOV79sYch8v1UXw07q2mM5Hj',
75-
'xS26LDt+YfIj/cwD+QkcBvZfwA==',
76-
'-----END PRIVATE KEY-----',
77-
].join('\n')
33+
/** DER type-length-value, for the one self-signed certificate below. */
34+
function der(tag: number, ...parts: Buffer[]): Buffer {
35+
const content = Buffer.concat(parts)
36+
const length =
37+
content.length < 0x80
38+
? Buffer.from([content.length])
39+
: content.length < 0x100
40+
? Buffer.from([0x81, content.length])
41+
: Buffer.from([0x82, content.length >> 8, content.length & 0xff])
42+
return Buffer.concat([Buffer.from([tag]), length, content])
43+
}
44+
45+
function derOid(oid: string): Buffer {
46+
const [first, second, ...rest] = oid.split('.').map(Number)
47+
const bytes = [first * 40 + second]
48+
for (const arc of rest) {
49+
const septets = [arc & 0x7f]
50+
for (let value = arc >> 7; value > 0; value >>= 7) septets.unshift((value & 0x7f) | 0x80)
51+
bytes.push(...septets)
52+
}
53+
return der(0x06, Buffer.from(bytes))
54+
}
55+
56+
function derUtcTime(date: Date): Buffer {
57+
const text = date.toISOString().replace(/[-:T]/g, '').slice(2, 14)
58+
return der(0x17, Buffer.from(`${text}Z`))
59+
}
60+
61+
/**
62+
* A self-signed RSA certificate (CN=sim-test-app) generated for this test run,
63+
* so no key material is committed.
64+
*/
65+
function generateTestCertificate(): { certificate: string; privateKey: string } {
66+
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
67+
const sha256WithRsa = der(0x30, derOid('1.2.840.113549.1.1.11'), der(0x05))
68+
const name = der(
69+
0x30,
70+
der(0x31, der(0x30, derOid('2.5.4.3'), der(0x0c, Buffer.from('sim-test-app'))))
71+
)
72+
const now = Date.now()
73+
const tbs = der(
74+
0x30,
75+
der(0xa0, der(0x02, Buffer.from([0x02]))),
76+
der(0x02, Buffer.from([0x01])),
77+
sha256WithRsa,
78+
name,
79+
der(0x30, derUtcTime(new Date(now - 86_400_000)), derUtcTime(new Date(now + 365 * 86_400_000))),
80+
name,
81+
publicKey.export({ type: 'spki', format: 'der' })
82+
)
83+
const signature = createSign('sha256').update(tbs).sign(privateKey)
84+
const certificate = der(0x30, tbs, sha256WithRsa, der(0x03, Buffer.from([0x00]), signature))
85+
const pem = (label: string, body: Buffer) =>
86+
`-----BEGIN ${label}-----\n${body
87+
.toString('base64')
88+
.match(/.{1,64}/g)
89+
?.join('\n')}\n-----END ${label}-----`
90+
return {
91+
certificate: pem('CERTIFICATE', certificate),
92+
privateKey: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
93+
}
94+
}
95+
96+
const { certificate: TEST_CERTIFICATE, privateKey: TEST_PRIVATE_KEY } = generateTestCertificate()
7897

7998
const TENANT = '11111111-2222-3333-4444-555555555555'
8099
const CLIENT_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'

‎apps/sim/lib/credentials/microsoft-app-service-account.server.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,16 @@ async function verifyDataverseApplicationUser(environmentUrl: string, token: str
455455
)
456456
}
457457

458+
/** Whether a decrypted Microsoft app credential secret is a keyless (federated) one. */
459+
export function isKeylessMicrosoftAppSecret(decrypted: string): boolean {
460+
try {
461+
const parsed = storedSecretSchema.safeParse(JSON.parse(decrypted))
462+
return parsed.success && parsed.data.config.authType === 'federated'
463+
} catch {
464+
return false
465+
}
466+
}
467+
458468
function readStoredSubject(storedSecret: Record<string, unknown> | null | undefined) {
459469
const parsed = storedSecretSchema.safeParse(storedSecret)
460470
return parsed.success && parsed.data.config.authType === 'federated'

‎apps/sim/lib/knowledge/application/connectors.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,8 @@ describe('knowledge connector application use cases', () => {
388388
'https://www.googleapis.com/auth/admin.directory.domain.readonly',
389389
]
390390
: ['https://www.googleapis.com/auth/drive.readonly'],
391-
'admin@corp.com'
391+
'admin@corp.com',
392+
{ service: 'google-drive' }
392393
)
393394
}
394395
)
@@ -993,7 +994,8 @@ describe('organization connector credential authorization', () => {
993994
googleDriveConnectorMeta.auth.mode === 'oauth'
994995
? googleDriveConnectorMeta.auth.adminServiceAccountScopes
995996
: undefined,
996-
'admin@corp.com'
997+
'admin@corp.com',
998+
{ service: 'google-drive' }
997999
)
9981000
})
9991001

‎apps/sim/lib/oauth/credential-service.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,22 @@ describe('Google service-account token minting', () => {
12151215
expect(JSON.stringify(loggedCalls('error'))).not.toContain('xxxx')
12161216
})
12171217

1218+
it('signs the same JSON-key request whether or not the caller names its service', async () => {
1219+
queueTableRows(credential, [row])
1220+
await getServiceAccountToken('credential-1', [driveScope], 'member@example.com')
1221+
queueTableRows(credential, [row])
1222+
await getServiceAccountToken('credential-1', [driveScope], 'member@example.com', {
1223+
service: 'google-drive',
1224+
})
1225+
1226+
const [withoutService, withService] = fetchMock.mock.calls.map(([url, init]) => [
1227+
String(url),
1228+
String(init?.body),
1229+
])
1230+
expect(fetchMock).toHaveBeenCalledTimes(2)
1231+
expect(withService).toEqual(withoutService)
1232+
})
1233+
12181234
describe('a keyless credential', () => {
12191235
const keylessSecret = {
12201236
decrypted: JSON.stringify({

0 commit comments

Comments
 (0)