Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
supporting recaptcha verdict for auth blocking functions
  • Loading branch information
Xiaoshouzi-gh committed Nov 2, 2023
commit ea509af9b19c3e90c31a8fff81bb164ef7ec2d9c
64 changes: 53 additions & 11 deletions spec/common/providers/identity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as identity from "../../../src/common/providers/identity";

const EVENT = "EVENT_TYPE";
const now = new Date();
const TEST_NAME = "John Doe";

describe("identity", () => {
describe("userRecordConstructor", () => {
Expand Down Expand Up @@ -232,14 +233,14 @@ describe("identity", () => {
describe("parseProviderData", () => {
const decodedUserInfo = {
provider_id: "google.com",
display_name: "John Doe",
display_name: TEST_NAME,
photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg",
uid: "1234567890",
email: "[email protected]",
};
const userInfo = {
providerId: "google.com",
displayName: "John Doe",
displayName: TEST_NAME,
photoURL: "https://lh3.googleusercontent.com/1234567890/photo.jpg",
uid: "1234567890",
email: "[email protected]",
Expand Down Expand Up @@ -340,12 +341,12 @@ describe("identity", () => {
uid: "abcdefghijklmnopqrstuvwxyz",
email: "[email protected]",
email_verified: true,
display_name: "John Doe",
display_name: TEST_NAME,
phone_number: "+11234567890",
provider_data: [
{
provider_id: "google.com",
display_name: "John Doe",
display_name: TEST_NAME,
photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg",
email: "[email protected]",
uid: "1234567890",
Expand All @@ -366,7 +367,7 @@ describe("identity", () => {
provider_id: "password",
email: "[email protected]",
uid: "[email protected]",
display_name: "John Doe",
display_name: TEST_NAME,
},
],
password_hash: "passwordHash",
Expand Down Expand Up @@ -407,11 +408,11 @@ describe("identity", () => {
phoneNumber: "+11234567890",
emailVerified: true,
disabled: false,
displayName: "John Doe",
displayName: TEST_NAME,
providerData: [
{
providerId: "google.com",
displayName: "John Doe",
displayName: TEST_NAME,
photoURL: "https://lh3.googleusercontent.com/1234567890/photo.jpg",
email: "[email protected]",
uid: "1234567890",
Expand All @@ -435,7 +436,7 @@ describe("identity", () => {
},
{
providerId: "password",
displayName: "John Doe",
displayName: TEST_NAME,
photoURL: undefined,
email: "[email protected]",
uid: "[email protected]",
Expand Down Expand Up @@ -489,8 +490,9 @@ describe("identity", () => {
});

describe("parseAuthEventContext", () => {
const TEST_RECAPTCHA_SCORE = 0.9;
const rawUserInfo = {
name: "John Doe",
name: TEST_NAME,
granted_scopes:
"openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
id: "123456789",
Expand All @@ -516,6 +518,7 @@ describe("identity", () => {
user_agent: "USER_AGENT",
locale: "en",
raw_user_info: JSON.stringify(rawUserInfo),
recaptcha_score: TEST_RECAPTCHA_SCORE,
};
const context = {
locale: "en",
Expand All @@ -534,6 +537,7 @@ describe("identity", () => {
profile: rawUserInfo,
username: undefined,
isNewUser: false,
recaptchaScore: TEST_RECAPTCHA_SCORE,
},
credential: null,
params: {},
Expand Down Expand Up @@ -563,6 +567,7 @@ describe("identity", () => {
oauth_refresh_token: "REFRESH_TOKEN",
oauth_token_secret: "OAUTH_TOKEN_SECRET",
oauth_expires_in: 3600,
recaptcha_score: TEST_RECAPTCHA_SCORE,
};
const context = {
locale: "en",
Expand All @@ -581,6 +586,7 @@ describe("identity", () => {
profile: rawUserInfo,
username: undefined,
isNewUser: false,
recaptchaScore: TEST_RECAPTCHA_SCORE,
},
credential: {
claims: undefined,
Expand Down Expand Up @@ -619,14 +625,14 @@ describe("identity", () => {
uid: "abcdefghijklmnopqrstuvwxyz",
email: "[email protected]",
email_verified: true,
display_name: "John Doe",
display_name: TEST_NAME,
phone_number: "+11234567890",
provider_data: [
{
provider_id: "oidc.provider",
email: "[email protected]",
uid: "[email protected]",
display_name: "John Doe",
display_name: TEST_NAME,
},
],
photo_url: "https://lh3.googleusercontent.com/1234567890/photo.jpg",
Expand All @@ -647,6 +653,7 @@ describe("identity", () => {
oauth_token_secret: "OAUTH_TOKEN_SECRET",
oauth_expires_in: 3600,
raw_user_info: JSON.stringify(rawUserInfo),
recaptcha_score: TEST_RECAPTCHA_SCORE,
};
const context = {
locale: "en",
Expand All @@ -665,6 +672,7 @@ describe("identity", () => {
providerId: "oidc.provider",
profile: rawUserInfo,
isNewUser: true,
recaptchaScore: TEST_RECAPTCHA_SCORE,
},
credential: {
claims: undefined,
Expand Down Expand Up @@ -762,4 +770,38 @@ describe("identity", () => {
);
});
});

describe("generateRequestPayload", () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"generateResponsePayload"

const DISPLAY_NAME_FILED = "displayName";
const TEST_RESPONSE = {
displayName: TEST_NAME,
recaptchaPassed: false,
} as identity.BeforeCreateResponse;

const EXPECT_PAYLOAD = {
userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED },
recaptchaPassed: false,
};

const TEST_RESPONSE_RECAPTCHA_UNDEFINED = {
displayName: TEST_NAME,
} as identity.BeforeSignInResponse;

const EXPECT_PAYLOAD_UNDEFINED = {
userRecord: { displayName: TEST_NAME, updateMask: DISPLAY_NAME_FILED },
};
it("should return empty string on undefined response", () => {
expect(identity.generateRequestPayload()).to.eq("");
});

it("should exclude recaptchaPass field from updateMask", () => {
expect(identity.generateRequestPayload(TEST_RESPONSE)).to.deep.equal(EXPECT_PAYLOAD);
});

it("should not return recaptchaPass if undefined", () => {
const payload = identity.generateRequestPayload(TEST_RESPONSE_RECAPTCHA_UNDEFINED);
expect(payload.hasOwnProperty("recaptchaPassed")).to.be.false;
expect(payload).to.deep.equal(EXPECT_PAYLOAD_UNDEFINED);
});
});
});
50 changes: 38 additions & 12 deletions src/common/providers/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export interface AdditionalUserInfo {
profile?: any;
username?: string;
isNewUser: boolean;
recaptchaScore?: number;
}

/** The credential component of the auth event context */
Expand Down Expand Up @@ -338,8 +339,13 @@ export interface AuthBlockingEvent extends AuthEventContext {
data: AuthUserRecord;
}

/** The base handler response type for beforeCreate and beforeSignIn blocking events*/
export interface BlockingFunctionResponse {
recaptchaPassed?: boolean;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since all fields are optional in BeforeCreateResponse and BeforeSignInResponse, should we put them all here? I understand this will be a much larger refactor, so this change is very optional.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, given this is a public interface, I think we should keep it as you have it here. Maybe we can have an internal/hidden interface which consolidates all the fields, mainly as return type for generatePayloadRequest.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason why separating this out is this new interface will be used in the beforeEmailSent trigger. And in the beforeEmailSent response, recaptchaPassed is the only field we support.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense.. Since the fields are all optional, technically, we could use the same Response for beforeEmailSent trigger. But what you have is cleaner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updates from API proposal - we are going to rename recaptchaPassed to recaptchaActionOverride and add this property to both BeforeCreateResponse and BeforeSignInResponse same as Pavithra suggested here.

}

/** The handler response type for beforeCreate blocking events */
export interface BeforeCreateResponse {
export interface BeforeCreateResponse extends BlockingFunctionResponse {
displayName?: string;
disabled?: boolean;
emailVerified?: boolean;
Expand Down Expand Up @@ -423,6 +429,7 @@ export interface DecodedPayload {
oauth_refresh_token?: string;
oauth_token_secret?: string;
oauth_expires_in?: number;
recaptcha_score?: number;
[key: string]: any;
}

Expand Down Expand Up @@ -640,9 +647,38 @@ function parseAdditionalUserInfo(decodedJWT: DecodedPayload): AdditionalUserInfo
profile,
username,
isNewUser: decodedJWT.event_type === "beforeCreate" ? true : false,
recaptchaScore: decodedJWT.recaptcha_score,
};
}

/** Helper to generate payload to GCIP from client request.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add newline after /**

* @internal
*/
export function generateRequestPayload(
authResponse?: BeforeCreateResponse | BeforeSignInResponse
): any {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know exactly the shape of the data GCIP expects? If we do then we could statically type the return value here instead of using any, otherwise not really a huge deal.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, let's avoid 'any' if possible. I guess BeforeSignInResponse is the type to use? (Or if we consolidate all fields into BlockingFunctionsResponse we could use that, in future)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeforeSignInResponse is the interface for blocking function returns for developers (e.g. developers can return displayName, emailVeried etc field when they implemented the blocking function). The requestPayload(responsePayload) here is (will be) a new different interface that follows GCIP backend proto. They are very similar but nested a bit differently. This is also the reason why the naming is kind of confusing, it is generated from the blocking function response as a request to GCIP backend.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a new interface for this.

if (!authResponse) {
return "";
}

const { recaptchaPassed, ...formattedAuthResponse } = authResponse;
const result = {} as any;
const updateMask = getUpdateMask(formattedAuthResponse);

if (updateMask.length !== 0) {
result.userRecord = {
...formattedAuthResponse,
updateMask,
};
}

if (recaptchaPassed !== undefined) {
result.recaptchaPassed = recaptchaPassed;
}

return result;
}

/** Helper to get the Credential from the decoded jwt */
function parseAuthCredential(decodedJWT: DecodedPayload, time: number): Credential {
if (
Expand Down Expand Up @@ -801,7 +837,6 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1
: handler.length === 2
? await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt)
: await auth.getAuth(getApp())._verifyAuthBlockingToken(req.body.data.jwt, "run.app");

const authUserRecord = parseAuthUserRecord(decodedPayload.user_record);
const authEventContext = parseAuthEventContext(decodedPayload, projectId);

Expand All @@ -818,16 +853,7 @@ export function wrapHandler(eventType: AuthBlockingEventType, handler: HandlerV1
}

validateAuthResponse(eventType, authResponse);
const updateMask = getUpdateMask(authResponse);
const result =
updateMask.length === 0
? {}
: {
userRecord: {
...authResponse,
updateMask,
},
};
const result = generateRequestPayload(authResponse);

res.status(200);
res.setHeader("Content-Type", "application/json");
Expand Down