Skip to content

Commit

Permalink
Add and verify OIDC/SAML flows (#3927)
Browse files Browse the repository at this point in the history
Includes assertions thrown for generic OIDC/SAML IdP providers that parallel GCIP behavior. Verified that existing signInWithIdp flows work against JSSDK Auth Demo page.

Corresponding internal bug: b/192387796
  • Loading branch information
lisajian committed Dec 7, 2021
1 parent 412bdf1 commit 31a7eec
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 5 deletions.
55 changes: 50 additions & 5 deletions src/emulator/auth/operations.ts
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }) };
}
}

Expand Down Expand Up @@ -2031,9 +2051,11 @@ function issueTokens(
{
extraClaims,
secondFactor,
signInAttributes,
}: {
extraClaims?: Record<string, unknown>;
secondFactor?: SecondFactorRecord;
signInAttributes?: unknown;
} = {}
): { idToken: string; refreshToken?: string; expiresIn: string } {
user = state.updateUserByLocalId(user.localId, { lastRefreshAt: new Date().toISOString() });
Expand All @@ -2051,6 +2073,7 @@ function issueTokens(
secondFactor,
usageMode,
tenantId,
signInAttributes,
});
const refreshToken =
state.usageMode === UsageMode.DEFAULT
Expand Down Expand Up @@ -2119,6 +2142,7 @@ function generateJwt(
secondFactor,
usageMode,
tenantId,
signInAttributes,
}: {
projectId: string;
signInProvider: string;
Expand All @@ -2127,6 +2151,7 @@ function generateJwt(
secondFactor?: SecondFactorRecord;
usageMode?: string;
tenantId?: string;
signInAttributes?: unknown;
}
): string {
const identities: Record<string, string[]> = {};
Expand Down Expand Up @@ -2173,6 +2198,7 @@ function generateJwt(
sign_in_second_factor: secondFactor?.provider,
usage_mode: usageMode,
tenant: tenantId,
sign_in_attributes: signInAttributes,
},
};
/* eslint-enable camelcase */
Expand Down Expand Up @@ -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;
Expand All @@ -2397,7 +2424,7 @@ function fakeFetchUserInfoFromIdp(
photoUrl,
};

let federatedId: string;
let federatedId = rawId;
/* eslint-disable camelcase */
switch (providerId) {
case "google.com": {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
}
Expand Down
14 changes: 14 additions & 0 deletions src/emulator/auth/widget_ui.ts
Expand Up @@ -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.'
Expand Down Expand Up @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions src/test/emulators/auth/idp.spec.ts
Expand Up @@ -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);
});
});
});

0 comments on commit 31a7eec

Please sign in to comment.