Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [2.UI][GCP Integration] Handle GCP Marketplace user sign up. #681

Merged
merged 2 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions app/models/portal/mutations.graphqls
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
mutation adminCreatePortalUser($email: String!, $providerUserID: ID!) {
adminCreatePortalUser(email: $email, providerUserID: $providerUserID) {
mutation adminCreatePortalUser ($email: String!, $providerUserID: ID!, $gcpAccountID: ID) {
adminCreatePortalUser (email: $email, providerUserID: $providerUserID, gcpAccountID: $gcpAccountID) {
portalUserID
email
iconURL
Expand Down
94 changes: 73 additions & 21 deletions app/routes/api.auth.auth0/route.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import jwt_decode from "jwt-decode"
import type { ActionFunction, LoaderFunction } from "@remix-run/node"
import { authenticator } from "~/utils/auth.server"

// The loader handles the case where the page is loaded via a GET request.
// This is where a standard GET browser request for login or signup is handled.
export let loader: LoaderFunction = ({ request }) => {

const url = new URL(request.url)
const signupField = url.searchParams.get("signup")
url.searchParams.append("prompt", "login")

if (signupField) {
if (url.searchParams.get("signup")) {
url.searchParams.append("screen_hint", "signup")
const signupRequest = new Request(url.toString(), request)
return authenticator.authenticate("auth0", signupRequest, {
Expand All @@ -22,33 +25,82 @@ export let loader: LoaderFunction = ({ request }) => {
})
}

// The action handles the case where the page is loaded via a POST request.
// This is where the POST redirect from a GCP Marketplace signup is handled.
export let action: ActionFunction = async ({ request }) => {
const formData = await request.formData()
const logoutField = formData.get("logout")
const signupField = formData.get("signup")

const url = new URL(request.url)
const formData = await request.formData();

if (logoutField) {
return authenticator.logout(request, {
redirectTo: url.origin,
})
// Possible scenarios:

// 1. GCP Marketplace signup - a POST redirect from the GCP Marketplace containing a JWT
const gcpMarketplaceToken = formData.get("x-gcp-marketplace-token") as string | null;
if (gcpMarketplaceToken) {
return handleGCPMarketplaceSignup(request, gcpMarketplaceToken);
}

if (signupField) {
url.searchParams.append("screen_hint", "signup")
url.searchParams.append("prompt", "login")
const signupRequest = new Request(url.toString(), request)
return authenticator.authenticate("auth0", signupRequest, {
successRedirect: "/account",
failureRedirect: "/",
})
// 2. Logout
if (formData.get("logout")) {
return handleLogout(request);
}

url.searchParams.append("prompt", "login")
const loginRequest = new Request(url.toString(), request)
// 3. Signup
if (formData.get("signup")) {
return handleSignup(request);
}

// 4. Login
return handleLogin(request);
}

// In the case where the page is loaded via a POST request from a GCP Marketplace signup,
// we need to decode the JWT containing the GCP account ID and pass it to Auth0 as a URL query param.
// Auth0 will then add it to a custom claim and pass it back to the callback page in the ID token.
async function handleGCPMarketplaceSignup(request: Request, gcpMarketplaceToken: string) {
const decodedToken = jwt_decode<{ sub: string }>(gcpMarketplaceToken);
const url = new URL(request.url);
url.searchParams.append("screen_hint", "signup");
url.searchParams.append("prompt", "login");

const gcpAccountID = decodedToken.sub;
if (gcpAccountID) {
url.searchParams.append("gcp_account_id", gcpAccountID);
}

const gcpSignupRequest = new Request(url.toString(), request);
return authenticator.authenticate("auth0", gcpSignupRequest, {
successRedirect: "/account",
failureRedirect: "/",
});
}

// handles the logout request from the client
async function handleLogout(request: Request) {
const url = new URL(request.url);
return authenticator.logout(request, {
redirectTo: url.origin,
});
}

// Handles a standard non-GCP Marketplace signup request from the client
async function handleSignup(request: Request) {
const url = new URL(request.url);
url.searchParams.append("screen_hint", "signup");
url.searchParams.append("prompt", "login");
const signupRequest = new Request(url.toString(), request);
return authenticator.authenticate("auth0", signupRequest, {
successRedirect: "/account",
failureRedirect: "/",
});
}

// Handles a standard login request from the client
async function handleLogin(request: Request) {
const url = new URL(request.url);
url.searchParams.append("prompt", "login");
const loginRequest = new Request(url.toString(), request);
return authenticator.authenticate("auth0", loginRequest, {
successRedirect: "/dashboard",
failureRedirect: url.origin,
})
});
}
108 changes: 80 additions & 28 deletions app/utils/auth.server.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { Authenticator } from "remix-auth"
import { Auth0Strategy } from "remix-auth-auth0"
import invariant from "tiny-invariant"
import jwt_decode from "jwt-decode"
import { getRequiredServerEnvVar } from "./environment"
import { sessionStorage } from "./session.server"
import { initPortalClient } from "~/models/portal/portal.server"
import { User as PortalUser } from "~/models/portal/sdk"
import { User as PortalUser, AdminCreatePortalUserMutationVariables } from "~/models/portal/sdk"
import { initAdminPortal } from "~/utils/adminPortal"
import { getSdk as portalSDKType } from "~/models/portal/sdk"

// Create an instance of the authenticator, pass a generic with what your
// strategies will return and will be stored in the session
export const authenticator = new Authenticator<{
accessToken: string
refreshToken: string | undefined
// extraParams: Auth0ExtraParams
user: PortalUser & {
auth0ID: string
email_verified?: boolean
Expand All @@ -22,7 +23,6 @@ export const authenticator = new Authenticator<{
export type AuthUser = {
accessToken: string
refreshToken: string | undefined
// extraParams: Auth0ExtraParams
user: PortalUser & {
auth0ID: string
email_verified?: boolean
Expand All @@ -38,55 +38,107 @@ let auth0Strategy = new Auth0Strategy(
audience: getRequiredServerEnvVar("AUTH0_AUDIENCE"),
scope: getRequiredServerEnvVar("AUTH0_SCOPE"),
},
async ({ accessToken, refreshToken, extraParams, profile }): Promise<AuthUser> => {
async ({ accessToken, refreshToken, profile, extraParams }): Promise<AuthUser> => {

const email = profile?._json?.email
const providerUserID = profile?.id

invariant(email, "email is not found")
invariant(providerUserID, "providerUserID is not found")

let portalUser: AuthUser["user"]
const portalSDK = initPortalClient({ token: accessToken })

const portal = initPortalClient({ token: accessToken })
let portalUser: AuthUser["user"]

try {
// First try and get the portal user using the JWT
const getPortalUserResponse = await portal.getPortalUser()

// If portal user found, set it as the user
portalUser = {
...(getPortalUserResponse?.getPortalUser as PortalUser),
auth0ID: providerUserID,
email_verified: profile._json?.email_verified,
}

// Case 1: Standard login
portalUser = await handlePortalUserFound(portalSDK, providerUserID, profile)

} catch (error) {

const err = error as Error

// If portal user not found, create it or return the existing user
if (err.message.includes("Response not OK. 404 Not Found")) {
const portalAdmin = await initAdminPortal(portal)
const user = await portalAdmin.adminCreatePortalUser({
email,
providerUserID,
})

portalUser = {
...(user.adminCreatePortalUser as PortalUser),
auth0ID: providerUserID,
email_verified: profile._json?.email_verified,

const portalAdmin = await initAdminPortal(portalSDK)
const idToken = extraParams.id_token

// Decode the ID token to check for GCP account ID
const gcpAccountIDCustomClaim = "https://custom.claims/gcp_account_id"
const decodedIdToken = idToken ? jwt_decode<{ [key: string]: string | undefined }>(idToken) : undefined
const gcpAccountID = decodedIdToken?.[gcpAccountIDCustomClaim]

if (gcpAccountID) {

// Case 2: ID token contains gcp_account_id (GCP Marketplace signup)
portalUser = await handleGCPMarketplaceRedirect(portalAdmin, email, providerUserID, gcpAccountID)

} else {

// Case 3: ID token does not contain gcp_account_id (Standard signup)
portalUser = await handleStandardSignup(portalAdmin, email, providerUserID)
}
} else {
throw error
}

}

return {
accessToken,
refreshToken,
// extraParams,
user: portalUser,
}
},
)

authenticator.use(auth0Strategy)
// Handles the case where the portal user is found (standard login)
async function handlePortalUserFound(portalSDK: ReturnType<typeof portalSDKType>, providerUserID: string, profile: any): Promise<AuthUser["user"]> {

const getPortalUserResponse = await portalSDK.getPortalUser()

return {
...(getPortalUserResponse?.getPortalUser as PortalUser),
auth0ID: providerUserID,
email_verified: profile._json?.email_verified,
}
}

// Handles the case where the portal user is not found and id token contains gcp_account_id (GCP Marketplace signup)
async function handleGCPMarketplaceRedirect(portalAdmin: ReturnType<typeof portalSDKType>, email: string, providerUserID: string, gcpAccountID: string): Promise<AuthUser["user"]> {

const createGCPPortalUserVars: AdminCreatePortalUserMutationVariables = {
email,
providerUserID,
gcpAccountID,
}

const user = await portalAdmin.adminCreatePortalUser(createGCPPortalUserVars)

return {
...(user.adminCreatePortalUser as PortalUser),
auth0ID: providerUserID,
email_verified: true,
}
}

// Handles the case where the portal user is not found and no gcp_account_id is found (standard signup)
async function handleStandardSignup(portalAdmin: ReturnType<typeof portalSDKType>, email: string, providerUserID: string): Promise<AuthUser["user"]> {

const createPortalUserVars: AdminCreatePortalUserMutationVariables = {
email,
providerUserID,
}

const user = await portalAdmin.adminCreatePortalUser(createPortalUserVars)

return {
...(user.adminCreatePortalUser as PortalUser),
auth0ID: providerUserID,
email_verified: true,
}
}

// Use the Auth0 strategy for authentication
authenticator.use(auth0Strategy)
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"@testing-library/react": "^14.2.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest-axe": "^3.5.4",
"@types/jsonwebtoken": "^9.0.7",
"@types/mersenne-twister": "^1.1.7",
"@types/node": "^18.13.0",
"@types/react": "^18.0.25",
Expand Down Expand Up @@ -126,4 +127,4 @@
"engines": {
"node": "18.x"
}
}
}
Loading
Loading