coder/site/src/xServices/auth/authXService.ts

401 lines
13 KiB
TypeScript

import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
successProfileUpdate: "Updated settings.",
}
export const checks = {
readAllUsers: "readAllUsers",
updateUsers: "updateUsers",
createUser: "createUser",
createTemplates: "createTemplates",
deleteTemplates: "deleteTemplates",
viewAuditLog: "viewAuditLog",
viewDeploymentValues: "viewDeploymentValues",
createGroup: "createGroup",
viewUpdateCheck: "viewUpdateCheck",
} as const
export const permissionsToCheck = {
[checks.readAllUsers]: {
object: {
resource_type: "user",
},
action: "read",
},
[checks.updateUsers]: {
object: {
resource_type: "user",
},
action: "update",
},
[checks.createUser]: {
object: {
resource_type: "user",
},
action: "create",
},
[checks.createTemplates]: {
object: {
resource_type: "template",
},
action: "update",
},
[checks.deleteTemplates]: {
object: {
resource_type: "template",
},
action: "delete",
},
[checks.viewAuditLog]: {
object: {
resource_type: "audit_log",
},
action: "read",
},
[checks.viewDeploymentValues]: {
object: {
resource_type: "deployment_flags",
},
action: "read",
},
[checks.createGroup]: {
object: {
resource_type: "group",
},
action: "create",
},
[checks.viewUpdateCheck]: {
object: {
resource_type: "update_check",
},
action: "read",
},
} as const
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>
export type AuthenticatedData = {
user: TypesGen.User
permissions: Permissions
}
export type UnauthenticatedData = {
hasFirstUser: boolean
authMethods: TypesGen.AuthMethods
}
export type AuthData = AuthenticatedData | UnauthenticatedData
export const isAuthenticated = (data?: AuthData): data is AuthenticatedData =>
data !== undefined && "user" in data
const loadInitialAuthData = async (): Promise<AuthData> => {
const authenticatedUser = await API.getAuthenticatedUser()
if (authenticatedUser) {
const permissions = (await API.checkAuthorization({
checks: permissionsToCheck,
})) as Permissions
return {
user: authenticatedUser,
permissions,
}
}
const [hasFirstUser, authMethods] = await Promise.all([
API.hasFirstUser(),
API.getAuthMethods(),
])
return {
hasFirstUser,
authMethods,
}
}
const signIn = async (
email: string,
password: string,
): Promise<AuthenticatedData> => {
await API.login(email, password)
const [user, permissions] = await Promise.all([
API.getAuthenticatedUser(),
API.checkAuthorization({
checks: permissionsToCheck,
}),
])
return {
user: user as TypesGen.User,
permissions: permissions as Permissions,
}
}
const signOut = async () => {
// Get app hostname so we can see if we need to log out of app URLs.
// We need to load this before we log out of the API as this is an
// authenticated endpoint.
const appHost = await API.getApplicationsHost()
const [authMethods] = await Promise.all([
API.getAuthMethods(), // Antecipate and load the auth methods
API.logout(),
])
// Logout the app URLs
if (appHost.host !== "") {
const { protocol, host } = window.location
const redirect_uri = encodeURIComponent(`${protocol}//${host}/login`)
// The path doesn't matter but we use /api because the dev server
// proxies /api to the backend.
const uri = `${protocol}//${appHost.host.replace(
"*",
"coder-logout",
)}/api/logout?redirect_uri=${redirect_uri}`
return {
redirectUrl: uri,
}
}
return {
hasFirstUser: true,
authMethods,
} as UnauthenticatedData
}
export interface AuthContext {
error?: Error | unknown
updateProfileError?: Error | unknown
data?: AuthData
}
export type AuthEvent =
| { type: "SIGN_OUT" }
| { type: "SIGN_IN"; email: string; password: string }
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogBsATimEAzDIAcAFgUB2VTJ26ANCACeiAIwaADKsLKFtu-dsBfRwbRZc+IqQolyUBuS0ECJEvgBufADWXmTkAWL8gsKiSBKICubKhKpSyhoArAbGCGaqGoRSlVXVlfnOrhg4eATEsb7+gWAATl18XYQ8ADb4AGZ9ALatFPGpiSRCImKSCLLySmqa2ro6RaYFAEzWDscK9SBuTZ6EMOhCfgCqsN1BIYThUUQ3ALJgCQLzySW6Uy2VyBV2JXyUhMFRqcLqLnOjQ8LRudygj2e3V6-SGowm1zA6B+fySi1Sy32MnyhHyyksYK2ug0EJM1IURxO9jOFxRnyJ6IACt1xiRYKQRLAXpQ3uQItFCABjTBgRWRYVdUXi5LwWb-BYpUDLZRScygvKFIyIGQaQ4FHnI5r827tDVaiXkKXYvoDYboMaapUqtVusUe3W8fWAimIE1mnIW1l0rImOE1BENdxOwkuvw-LB8CBS4Iy94K75EzCFiMgOYGoEIZRUwgyVT5BQmfaW4omGxZGxcuwOrNXNHtfNVou0b24v0ByYVgtF0kA8lG2PN1vtzvd0zszmD06I3nZ7yUCABAa9EYkQahCB32j3QUAEQAggAVACibEFACUqAAMQYAAZL8V3rGMSjbGQKihLsISkOlaWHS4WjPSBLx4a9byIVAeAgfBXRwx8S1COUPkIE8rgwi9yCvPgbzvQh8MIoUSLABB3kVIiRAAbXMABdCDo3XaD8lgpCpAQq0EA0eTUL5KZzywjiWIIoi-EFDjpx6H08X9AlqPQ2JMPo7DGNw9S2OIyy7y4iieINAThL1MlDTScTJPg3dGxkfZFNPUy6OIWBMDeB8wFoJgvw-NhsGwAAJNgAGkvwATREtdPM7VQrE0XyTF7coB0PQKaOCy9xXCsc-ASxKUrAQxpXI+UiGMmIKDM0KaoFdp6sawwHIiJzkhcrKPOWIqkOscFZNTfYOXtY9HQqrqQuqnN0QGprdJxX18UDDrlO6zbaqgHahu43jyHGtzV0m0x9jyxQ5p7ExzBhUrB3Kkz1qqsLCEGPhkAgSAIsfP8vxilgvz-T8f3q1KMomht9g+8ocnMGQCtZDRZAPLkMyREc-pU+jNuB0HwcVEQb01S6-zAGBKC6TxaAAYTfFgOa-EC2ChmG4YR+KkuRzL7sgsT0bMQhzCUcxpMK20vsPBRieO2iAfCqmwYgJU6ZIBmksGpmWe6dmOaoFhgL-L4Behr9Yfh79ReStKJcjdy0Yx0Fsdx+b23MX7OvJnqgZBvXCC6ZmwFZzSLpN3ayNlNqqNWsnTsB3XwZj822e2pOrscm67q9h6GzMBQrDy7cZJ7DR1ZQlbSdDrOdcj3PY-jwuGt2mcDsMo6M7bjbs87-W87ji3e8G4a+FG-ihNRqCq5rtsO3r0wpG0ZvMzQ0eqtVVAunmQwIai5931d7Avw5+4-wYD9PdrKNsqmsoOQyBM3sQZ6pBDidDax9T7oHPqxBO2AQFnxaqnSimtKoU2gWA6ykDkHFxGqXZektRI5U-ooBkiZZIKFyHvEmB8gFH0VCfM+qDtroL2vpOcRkR6UKQdQ0B4CNL0I4Wfeei9brYPLlLPBjcCE-18m2KwGtWFa0CIwVgbAqD3A-CvaWWgYSEN-iUDIZpUxpiqBoQBZ52g0HQLAssoczFqM8k2WCW5N6+X2OYeShMTgyNbspUxdAB4GXnMpaxOD35-w0XLCRrJ0ZWH0QYqQRiW4UOVKqSI7RAJG1gOgTEXQLEUQVMdRJaoUlpIyU8Lo-CsGuWEbg5YmhCD7B3nU-YA5lA6HVhCZxYjoRshyDvJQ+NVCAIAO7IABH4QCfQPwqlSV0dJmT6DMHYJwGx1Sm7Yx0I3SwziTBOLqWaLQdIpC2HyKoLZ5gESInIIWOAYgEHrUCZU4JJRsY0iIT2akZoPEUJMX4GY9zHoIDbIcdGLzt4qFhDEpCgDzqZKWYgVQ+xWSyCyOC2okK+paRFGGHUML-mmnNNorZbIwUxI+Upc6E5qzYq2LUuQwKSitnymrI8+8lJyIYkxe8zELlfj0l0bFVcaRlCklvRspzjGILZVZEgkVCAzj5Y3AV+MfIQjyEy8hLLxUWXZRfOVRVsiKqVhCRuhxtgaBVY3A5MgxX-XMmpCB7E7K-CCX8oq+RnnaIKJa+J6rrUSrvHykwHZZq+X2bScwYbzUSTUBCr1QUfWbSlX6p1lctlusKp2Q4JLY1hzOmixOfdii-MrlIiotKPpyCtdm8e1N9YJsdYWqCmyshyH9vigoVhkWxIre3CO1aDbkHpuMRm3cZ51tft7BtqhzAZoVga+aGhexuOOJmtalaO69qnj3fqRc+UKHpIQIq87S25GXZnMea69Y7qhPuswxVCptnKNsOQ7Z2xUhPYfCmYV-WBtLVOjkJqzUkP6TGldp10EX0IFynlcroRywsI4iEu6ArAdPVQmhKDa0yqg0m1e+NNFwZ3BCNswdkPvuIGB2tcqakuPlgR4h2MWzMgAxartwDeEoLtf1dB-rXVBoQyQljqHOFfq+q2v9jHG7mqUKqm55N-UuN4-NT6DGdD5C0Gp-Ypq31eL8HcsdFcG0yA+rB5QtHijOKOYoNWgD8nJNGUU6F2GxK9LlvSTIcLGn5C2ZUNpLj5CxIUFSD6XmuyDOGeiMZXQJlgCmTMkp2LGm1OUFCHQORGkyEVqoZQbTNly2-poaERy6kh0pYl5LrZpLNIy1l2Sm5dAkPRs4wz+NlDOGcEAA */
createMachine(
{
id: "authState",
predictableActionArguments: true,
tsTypes: {} as import("./authXService.typegen").Typegen0,
schema: {
context: {} as AuthContext,
events: {} as AuthEvent,
services: {} as {
loadInitialAuthData: {
data: Awaited<ReturnType<typeof loadInitialAuthData>>
}
signIn: {
data: Awaited<ReturnType<typeof signIn>>
}
updateProfile: {
data: TypesGen.User
}
signOut: {
data: Awaited<ReturnType<typeof signOut>>
}
},
},
initial: "loadingInitialAuthData",
states: {
loadingInitialAuthData: {
invoke: {
src: "loadInitialAuthData",
onDone: [
{
target: "signedIn",
actions: ["assignData", "clearError"],
cond: "isAuthenticated",
},
{
target: "configuringTheFirstUser",
actions: ["assignData", "clearError"],
cond: "needSetup",
},
{
target: "signedOut",
actions: ["assignData", "clearError"],
},
],
onError: {
target: "signedOut",
actions: ["assignError"],
},
},
},
signedOut: {
on: {
SIGN_IN: {
target: "signingIn",
},
},
},
signingIn: {
entry: "clearError",
invoke: {
src: "signIn",
id: "signIn",
onDone: [
{
target: "signedIn",
actions: "assignData",
},
],
onError: [
{
actions: "assignError",
target: "signedOut",
},
],
},
},
signedIn: {
type: "parallel",
on: {
SIGN_OUT: {
target: "signingOut",
},
},
states: {
profile: {
initial: "idle",
states: {
idle: {
initial: "noError",
states: {
noError: {},
error: {},
},
on: {
UPDATE_PROFILE: {
target: "updatingProfile",
},
},
},
updatingProfile: {
entry: "clearUpdateProfileError",
invoke: {
src: "updateProfile",
onDone: [
{
actions: ["updateUser", "notifySuccessProfileUpdate"],
target: "#authState.signedIn.profile.idle.noError",
},
],
onError: [
{
actions: "assignUpdateProfileError",
target: "#authState.signedIn.profile.idle.error",
},
],
},
},
},
},
},
},
signingOut: {
invoke: {
src: "signOut",
id: "signOut",
onDone: [
{
actions: ["clearData", "clearError", "redirect"],
cond: "hasRedirectUrl",
},
{
actions: ["clearData", "clearError"],
target: "signedOut",
},
],
onError: [
{
actions: "assignError",
target: "signedIn",
},
],
},
},
configuringTheFirstUser: {
on: {
SIGN_IN: {
target: "signingIn",
},
},
},
},
},
{
services: {
loadInitialAuthData,
signIn: (_, { email, password }) => signIn(email, password),
signOut,
updateProfile: async ({ data }, event) => {
if (!data) {
throw new Error("Authenticated data is not loaded yet")
}
if (isAuthenticated(data)) {
return API.updateProfile(data.user.id, event.data)
}
throw new Error("User not authenticated")
},
},
actions: {
assignData: assign({
data: (_, { data }) => data,
}),
clearData: assign({
data: (_) => undefined,
}),
assignError: assign({
error: (_, event) => event.data,
}),
clearError: assign({
error: (_) => undefined,
}),
updateUser: assign({
data: (context, event) => {
if (!context.data) {
throw new Error("No authentication data loaded")
}
return {
...context.data,
user: event.data,
}
},
}),
assignUpdateProfileError: assign({
updateProfileError: (_, event) => event.data,
}),
notifySuccessProfileUpdate: () => {
displaySuccess(Language.successProfileUpdate)
},
clearUpdateProfileError: assign({
updateProfileError: (_) => undefined,
}),
redirect: (_, { data }) => {
if (!("redirectUrl" in data)) {
throw new Error(
"Redirect only should be called with data.redirectUrl",
)
}
window.location.replace(data.redirectUrl)
},
},
guards: {
isAuthenticated: (_, { data }) => isAuthenticated(data),
needSetup: (_, { data }) =>
!isAuthenticated(data) && !data.hasFirstUser,
hasRedirectUrl: (_, { data }) => Boolean(data),
},
},
)