diff --git a/src/emulator/auth/operations.ts b/src/emulator/auth/operations.ts index 90fdb878860..79b3fd3e06f 100644 --- a/src/emulator/auth/operations.ts +++ b/src/emulator/auth/operations.ts @@ -1460,7 +1460,27 @@ function signInWithIdp( } } - let { response, rawId } = fakeFetchUserInfoFromIdp(providerId, claims); + // Generic SAML flow + let samlResponse: SamlResponse | undefined; + let signInAttributes = undefined; + if (normalizedUri.searchParams.get("SAMLResponse")) { + // Auth emulator purposefully does not parse SAML and expects SAML-related + // fields to be JSON objects. + samlResponse = JSON.parse(normalizedUri.searchParams.get("SAMLResponse")!) as SamlResponse; + signInAttributes = samlResponse.assertion?.attributeStatements; + + assert(samlResponse.assertion, "INVALID_IDP_RESPONSE ((Missing assertion in SAMLResponse.))"); + assert( + samlResponse.assertion.subject, + "INVALID_IDP_RESPONSE ((Missing assertion.subject in SAMLResponse.))" + ); + assert( + samlResponse.assertion.subject.nameId, + "INVALID_IDP_RESPONSE ((Missing assertion.subject.nameId in SAMLResponse.))" + ); + } + + let { response, rawId } = fakeFetchUserInfoFromIdp(providerId, claims, samlResponse); // Always return an access token, so that clients depending on it sorta work. // e.g. JS SDK creates credentials from accessTokens for most providers: @@ -1556,7 +1576,7 @@ function signInWithIdp( return { ...response, ...mfaPending(state, user, providerId) }; } else { user = state.updateUserByLocalId(user.localId, { lastLoginAt: Date.now().toString() }); - return { ...response, ...issueTokens(state, user, providerId) }; + return { ...response, ...issueTokens(state, user, providerId, { signInAttributes }) }; } } @@ -2031,9 +2051,11 @@ function issueTokens( { extraClaims, secondFactor, + signInAttributes, }: { extraClaims?: Record; secondFactor?: SecondFactorRecord; + signInAttributes?: unknown; } = {} ): { idToken: string; refreshToken?: string; expiresIn: string } { user = state.updateUserByLocalId(user.localId, { lastRefreshAt: new Date().toISOString() }); @@ -2051,6 +2073,7 @@ function issueTokens( secondFactor, usageMode, tenantId, + signInAttributes, }); const refreshToken = state.usageMode === UsageMode.DEFAULT @@ -2119,6 +2142,7 @@ function generateJwt( secondFactor, usageMode, tenantId, + signInAttributes, }: { projectId: string; signInProvider: string; @@ -2127,6 +2151,7 @@ function generateJwt( secondFactor?: SecondFactorRecord; usageMode?: string; tenantId?: string; + signInAttributes?: unknown; } ): string { const identities: Record = {}; @@ -2173,6 +2198,7 @@ function generateJwt( sign_in_second_factor: secondFactor?.provider, usage_mode: usageMode, tenant: tenantId, + sign_in_attributes: signInAttributes, }, }; /* eslint-enable camelcase */ @@ -2371,7 +2397,8 @@ function parseClaims(idTokenOrJsonClaims: string | undefined): IdpJwtPayload | u function fakeFetchUserInfoFromIdp( providerId: string, - claims: IdpJwtPayload + claims: IdpJwtPayload, + samlResponse?: SamlResponse ): { response: SignInWithIdpResponse; rawId: string; @@ -2397,7 +2424,7 @@ function fakeFetchUserInfoFromIdp( photoUrl, }; - let federatedId: string; + let federatedId = rawId; /* eslint-disable camelcase */ switch (providerId) { case "google.com": { @@ -2421,8 +2448,14 @@ function fakeFetchUserInfoFromIdp( }); break; } + case providerId.match(/^saml\./)?.input: + const nameId = samlResponse?.assertion?.subject?.nameId; + response.email = nameId && isValidEmailAddress(nameId) ? nameId : response.email; + response.emailVerified = true; + response.rawUserInfo = JSON.stringify(samlResponse?.assertion?.attributeStatements); + break; + case providerId.match(/^oidc\./)?.input: default: - federatedId = rawId; response.rawUserInfo = JSON.stringify(claims); break; } @@ -2766,6 +2799,17 @@ function updateTenant( return state.updateTenant(reqBody, ctx.params.query.updateMask); } +export interface SamlAssertion { + subject?: { + nameId?: string; + }; + attributeStatements?: unknown; +} + +export interface SamlResponse { + assertion?: SamlAssertion; +} + /* eslint-disable camelcase */ export interface FirebaseJwtPayload { // Standard fields: @@ -2796,6 +2840,7 @@ export interface FirebaseJwtPayload { second_factor_identifier?: string; usage_mode?: string; tenant?: string; + sign_in_attributes?: unknown; }; // ...and other fields that we don't care for now. } diff --git a/src/emulator/auth/widget_ui.ts b/src/emulator/auth/widget_ui.ts index ce862e75d33..74f1b303baa 100644 --- a/src/emulator/auth/widget_ui.ts +++ b/src/emulator/auth/widget_ui.ts @@ -33,6 +33,7 @@ var firebaseAppId = query.get('appId'); var apn = query.get('apn'); var ibi = query.get('ibi'); var appIdentifier = apn || ibi; +var isSamlProvider = !!providerId.match(/^saml\./); assert( appName || clientId || firebaseAppId || appIdentifier, 'Missing one of appName / clientId / appId / apn / ibi query params.' @@ -172,6 +173,19 @@ function finishWithUser(urlEncodedIdToken) { // Avoid URLSearchParams for browser compatibility. url += '?providerId=' + encodeURIComponent(providerId); url += '&id_token=' + urlEncodedIdToken; + + // Save reasonable defaults for SAML providers + if (isSamlProvider) { + var email = document.getElementById('email-input').value; + url += '&SAMLResponse=' + encodeURIComponent(JSON.stringify({ + assertion: { + subject: { + nameId: email, + }, + }, + })); + } + saveAuthEvent({ type: authType, eventId: eventId, diff --git a/src/test/emulators/auth/idp.spec.ts b/src/test/emulators/auth/idp.spec.ts index 3ac7248d0a6..e47010c6a8a 100644 --- a/src/test/emulators/auth/idp.spec.ts +++ b/src/test/emulators/auth/idp.spec.ts @@ -1006,4 +1006,144 @@ describeAuthEmulator("sign-in with credential", ({ authApi, getClock }) => { expect(afterFirstFactor.lastLoginAt).to.equal(beforeSignIn.lastLoginAt); expect(afterFirstFactor.lastRefreshAt).to.equal(beforeSignIn.lastRefreshAt); }); + + it("should error if SAMLResponse is missing assertion", async () => { + const samlResponse = {}; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp") + .query({ key: "fake-api-key" }) + .send({ + postBody: `providerId=saml.saml&id_token=${ + FAKE_GOOGLE_ACCOUNT.idToken + }&SAMLResponse=${JSON.stringify(samlResponse)}`, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.include("INVALID_IDP_RESPONSE"); + }); + }); + + it("should error if SAMLResponse is missing assertion.subject", async () => { + const samlResponse = { assertion: {} }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp") + .query({ key: "fake-api-key" }) + .send({ + postBody: `providerId=saml.saml&id_token=${ + FAKE_GOOGLE_ACCOUNT.idToken + }&SAMLResponse=${JSON.stringify(samlResponse)}`, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.include("INVALID_IDP_RESPONSE"); + }); + }); + + it("should error if SAMLResponse is missing assertion.subject.nameId", async () => { + const samlResponse = { assertion: { subject: {} } }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp") + .query({ key: "fake-api-key" }) + .send({ + postBody: `providerId=saml.saml&id_token=${ + FAKE_GOOGLE_ACCOUNT.idToken + }&SAMLResponse=${JSON.stringify(samlResponse)}`, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }) + .then((res) => { + expectStatusCode(400, res); + expect(res.body.error.message).to.include("INVALID_IDP_RESPONSE"); + }); + }); + + it("should create an account for generic SAML providers", async () => { + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp") + .query({ key: "fake-api-key" }) + .send({ + postBody: `providerId=saml.saml&id_token=${FAKE_GOOGLE_ACCOUNT.idToken}`, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }) + .then((res) => { + expectStatusCode(200, res); + expect(res.body.isNewUser).to.equal(true); + expect(res.body.email).to.equal(FAKE_GOOGLE_ACCOUNT.email); + expect(res.body.emailVerified).to.equal(true); + expect(res.body.federatedId).to.equal(FAKE_GOOGLE_ACCOUNT.rawId); + expect(res.body.oauthIdToken).to.equal(FAKE_GOOGLE_ACCOUNT.idToken); + expect(res.body.providerId).to.equal("saml.saml"); + expect(res.body).to.have.property("refreshToken").that.is.a("string"); + + // The ID Token used above does NOT contain name or photo, so the + // account created won't have those attributes either. + expect(res.body).not.to.have.property("displayName"); + expect(res.body).not.to.have.property("photoUrl"); + + const idToken = res.body.idToken; + const decoded = decodeJwt(idToken, { complete: true }) as { + header: JwtHeader; + payload: FirebaseJwtPayload; + } | null; + expect(decoded, "JWT returned by emulator is invalid").not.to.be.null; + expect(decoded!.header.alg).to.eql("none"); + expect(decoded!.payload).not.to.have.property("provider_id"); + expect(decoded!.payload.firebase) + .to.have.property("identities") + .eql({ + "saml.saml": [FAKE_GOOGLE_ACCOUNT.rawId], + email: [FAKE_GOOGLE_ACCOUNT.email], + }); + expect(decoded!.payload.firebase).to.have.property("sign_in_provider").equals("saml.saml"); + }); + }); + + it("should include fields in SAMLResponse for SAML providers", async () => { + const otherEmail = "otherEmail@gmail.com"; + const attributeStatements = { + name: "Jane Doe", + mail: "otherOtherEmail@gmail.com", + }; + const samlResponse = { assertion: { subject: { nameId: otherEmail }, attributeStatements } }; + + await authApi() + .post("/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp") + .query({ key: "fake-api-key" }) + .send({ + postBody: `providerId=saml.saml&id_token=${ + FAKE_GOOGLE_ACCOUNT.idToken + }&SAMLResponse=${JSON.stringify(samlResponse)}`, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }) + .then((res) => { + expectStatusCode(200, res); + expect(res.body.email).to.equal(otherEmail); + + const rawUserInfo = JSON.parse(res.body.rawUserInfo); + expect(rawUserInfo).to.eql(attributeStatements); + + const idToken = res.body.idToken; + const decoded = decodeJwt(idToken, { complete: true }) as { + header: JwtHeader; + payload: FirebaseJwtPayload; + } | null; + expect(decoded!.payload.firebase) + .to.have.property("sign_in_attributes") + .eql(attributeStatements); + }); + }); });