Skip to content

Commit 37dfa41

Browse files
committed
fix(sso): verify the SAML key pair and keep the private key out of logs
The registration log emitted the resolved provider config with only the client secret and IdP certificate redacted, so enabling encrypted assertions wrote the service provider's private key to a log line. It is redacted now, with a test that fails if it ever reaches one again. Both halves of the encryption pair are also parsed and compared rather than checked for PEM markers: a valid certificate paired with a valid key from a different pair used to save cleanly and then fail every sign-in. The tests generate real key material per run instead of committing any.
1 parent c73789c commit 37dfa41

2 files changed

Lines changed: 112 additions & 16 deletions

File tree

‎apps/sim/app/api/auth/sso/register/route.test.ts‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
/**
22
* @vitest-environment node
33
*/
4+
5+
import { execFileSync } from 'node:child_process'
6+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
7+
import { tmpdir } from 'node:os'
8+
import path from 'node:path'
49
import {
510
createMockRequest,
611
dbChainMock,
@@ -13,6 +18,7 @@ import {
1318
setEnv,
1419
setEnvFlags,
1520
} from '@sim/testing'
21+
import { loggerMock } from '@sim/testing/mocks/logger.mock'
1622
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1723

1824
const {
@@ -91,6 +97,17 @@ vi.mock('@/lib/core/security/input-validation.server', () => ({
9197

9298
import { POST } from '@/app/api/auth/sso/register/route'
9399

100+
type MockLogger = { info: { mock: { calls: unknown[][] } } }
101+
102+
/** The logger the route built at import time, so its calls can be inspected. */
103+
const routeLogger = loggerMock.createLogger.mock.calls.reduce<MockLogger | null>(
104+
(found, call, index) =>
105+
call[0] === 'SSORegisterRoute'
106+
? (loggerMock.createLogger.mock.results[index].value as MockLogger)
107+
: found,
108+
null
109+
)
110+
94111
const OIDC_BODY = {
95112
providerType: 'oidc' as const,
96113
providerId: 'acme-oidc',
@@ -364,8 +381,40 @@ describe('POST /api/auth/sso/register', () => {
364381
})
365382

366383
describe('SAML encrypted assertions', () => {
367-
const SP_CERT = `-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----`
368-
const SP_KEY = `-----BEGIN PRIVATE KEY-----\nREVG\n-----END PRIVATE KEY-----`
384+
/**
385+
* Real key material, because the route parses both halves and checks they
386+
* belong together. Generated per run rather than committed: a private key
387+
* in the repository is exactly what this PR is about not doing.
388+
*/
389+
const keyPair = (subject: string) => {
390+
const dir = mkdtempSync(path.join(tmpdir(), 'sim-sso-keys-'))
391+
execFileSync('openssl', [
392+
'req',
393+
'-x509',
394+
'-newkey',
395+
'rsa:2048',
396+
'-keyout',
397+
path.join(dir, 'key.pem'),
398+
'-out',
399+
path.join(dir, 'cert.pem'),
400+
'-days',
401+
'2',
402+
'-nodes',
403+
'-subj',
404+
`/CN=${subject}`,
405+
])
406+
const pair = {
407+
cert: readFileSync(path.join(dir, 'cert.pem'), 'utf8'),
408+
key: readFileSync(path.join(dir, 'key.pem'), 'utf8'),
409+
}
410+
rmSync(dir, { recursive: true, force: true })
411+
return pair
412+
}
413+
414+
const SP = keyPair('sim-test-sp')
415+
const OTHER = keyPair('sim-test-other')
416+
const SP_CERT = SP.cert
417+
const SP_KEY = SP.key
369418
const samlBody = (overrides: Record<string, unknown> = {}) => ({
370419
providerType: 'saml' as const,
371420
providerId: 'acme-saml',
@@ -396,9 +445,29 @@ describe('POST /api/auth/sso/register', () => {
396445
})
397446
/** The certificate travels in the metadata document, stripped of its PEM armor. */
398447
expect(samlConfig.spMetadata.metadata).toContain('use="encryption"')
399-
expect(samlConfig.spMetadata.metadata).toContain('QUJD')
448+
expect(samlConfig.spMetadata.metadata).toContain(
449+
SP_CERT.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '').replace(/\s+/g, '')
450+
)
400451
expect(samlConfig.spMetadata.metadata).not.toContain('BEGIN CERTIFICATE')
401-
expect(samlConfig.spMetadata.metadata).not.toContain('REVG')
452+
expect(samlConfig.spMetadata.metadata).not.toContain('PRIVATE KEY')
453+
})
454+
455+
it('never writes the private key to a log line', async () => {
456+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
457+
queueProviders([])
458+
459+
await POST(
460+
request(
461+
samlBody({ encryptAssertions: true, spEncryptionCert: SP_CERT, spDecryptionKey: SP_KEY })
462+
)
463+
)
464+
465+
/** The route logs its resolved provider config; the key must be redacted there. */
466+
const logged = (routeLogger?.info.mock.calls ?? [])
467+
.map((call) => JSON.stringify(call))
468+
.join('\n')
469+
expect(logged).not.toContain('PRIVATE KEY')
470+
expect(logged).toContain('[REDACTED]')
402471
})
403472

404473
it('leaves the metadata and key material alone when encryption is off', async () => {
@@ -441,7 +510,9 @@ describe('POST /api/auth/sso/register', () => {
441510
const res = await POST(request(samlBody({ encryptAssertions: true, ...overrides })))
442511

443512
expect(res.status).toBe(400)
444-
await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining('PEM') })
513+
await expect(res.json()).resolves.toMatchObject({
514+
error: expect.stringMatching(/PEM|matching pair/),
515+
})
445516
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
446517
})
447518

‎apps/sim/app/api/auth/sso/register/route.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createPrivateKey, createPublicKey } from 'node:crypto'
12
import { db, member, ssoDomain, ssoProvider } from '@sim/db'
23
import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider'
34
import { createLogger } from '@sim/logger'
@@ -92,23 +93,38 @@ function stripPemArmor(pem: string): string {
9293
.trim()
9394
}
9495

96+
/** The SubjectPublicKeyInfo of a certificate or private key, for comparing the two. */
97+
function publicKeyOf(pem: string, kind: 'certificate' | 'private key'): string {
98+
const key = kind === 'certificate' ? createPublicKey(pem) : createPublicKey(createPrivateKey(pem))
99+
return key.export({ type: 'spki', format: 'pem' }).toString()
100+
}
101+
95102
/**
96-
* Names the first problem with an encryption key pair, or null when both look
97-
* like PEM documents of the right kind. Catching it here turns what would
98-
* otherwise be a failed sign-in weeks later into an error on the save.
103+
* Names the first problem with an encryption key pair, or null when both parse
104+
* and belong together. A mismatched pair is the failure worth catching here:
105+
* each half is individually valid, so nothing complains until the identity
106+
* provider encrypts an assertion Sim cannot read, weeks later at sign-in.
99107
*/
100-
function describePemProblem(
108+
function describeKeyPairProblem(
101109
cert: string | undefined,
102110
privateKey: string | undefined
103111
): string | null {
104-
if (!cert?.includes('BEGIN CERTIFICATE')) {
105-
return 'Service provider certificate must be a PEM certificate beginning with -----BEGIN CERTIFICATE-----'
112+
let certificatePublicKey: string
113+
try {
114+
certificatePublicKey = publicKeyOf(cert ?? '', 'certificate')
115+
} catch {
116+
return 'Service provider certificate must be a PEM X.509 certificate beginning with -----BEGIN CERTIFICATE-----'
106117
}
107-
if (!privateKey?.includes('PRIVATE KEY')) {
118+
119+
let privateKeyPublicKey: string
120+
try {
121+
privateKeyPublicKey = publicKeyOf(privateKey ?? '', 'private key')
122+
} catch {
108123
return 'Service provider private key must be a PEM private key beginning with -----BEGIN PRIVATE KEY-----'
109124
}
110-
if (!stripPemArmor(cert) || !stripPemArmor(privateKey)) {
111-
return 'Service provider certificate and private key cannot be empty'
125+
126+
if (certificatePublicKey !== privateKeyPublicKey) {
127+
return 'Service provider certificate and private key are not a matching pair'
112128
}
113129
return null
114130
}
@@ -599,8 +615,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
599615
}
600616

601617
if (encryptAssertions) {
602-
const pemProblem = describePemProblem(spEncryptionCert, decryptionKey)
603-
if (pemProblem) return NextResponse.json({ error: pemProblem }, { status: 400 })
618+
const keyPairProblem = describeKeyPairProblem(spEncryptionCert, decryptionKey)
619+
if (keyPairProblem) return NextResponse.json({ error: keyPairProblem }, { status: 400 })
604620
}
605621

606622
const computedCallbackUrl =
@@ -711,6 +727,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
711727
? {
712728
...providerConfig.samlConfig,
713729
cert: REDACTED_MARKER,
730+
/** The service provider's own private key never reaches a log line. */
731+
...(providerConfig.samlConfig.spMetadata?.encPrivateKey
732+
? {
733+
spMetadata: {
734+
...providerConfig.samlConfig.spMetadata,
735+
encPrivateKey: REDACTED_MARKER,
736+
},
737+
}
738+
: {}),
714739
}
715740
: undefined,
716741
},

0 commit comments

Comments
 (0)