Skip to content

Commit

Permalink
Pass additional metadata to ~/.enso/credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
MrFlashAccount committed Mar 8, 2024
1 parent a3bf5a0 commit 0801dbe
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 42 deletions.
71 changes: 41 additions & 30 deletions app/ide-desktop/lib/client/src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,38 +156,49 @@ export function onOpenUrl(url: URL, window: () => electron.BrowserWindow) {
* The credentials file is placed in the user's home directory in the `.enso` subdirectory
* in the `credentials` file. */
function initSaveAccessTokenListener() {
electron.ipcMain.on(ipc.Channel.saveAccessToken, (event, accessToken: string | null) => {
event.preventDefault()
electron.ipcMain.on(
ipc.Channel.saveAccessToken,
(event, accessTokenPayload: SaveAccessTokenPayload | null) => {
event.preventDefault()

/** Home directory for the credentials file. */
const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}`
/** File name of the credentials file. */
const credentialsFileName = 'credentials'
/** System agnostic credentials directory home path. */
const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName)
/** Home directory for the credentials file. */
const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}`
/** File name of the credentials file. */
const credentialsFileName = 'credentials'
/** System agnostic credentials directory home path. */
const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName)

if (accessToken == null) {
try {
fs.unlinkSync(path.join(credentialsHomePath, credentialsFileName))
} catch {
// Ignored, most likely the path does not exist.
}
} else {
fs.mkdir(credentialsHomePath, { recursive: true }, error => {
if (error) {
logger.error(`Couldn't create ${credentialsDirectoryName} directory.`)
} else {
fs.writeFile(
path.join(credentialsHomePath, credentialsFileName),
accessToken,
innerError => {
if (innerError) {
logger.error(`Could not write to ${credentialsFileName} file.`)
}
}
)
if (accessTokenPayload == null) {
try {
fs.unlinkSync(path.join(credentialsHomePath, credentialsFileName))
} catch {
// Ignored, most likely the path does not exist.
}
})
} else {
fs.mkdir(credentialsHomePath, { recursive: true }, error => {
if (error) {
logger.error(`Couldn't create ${credentialsDirectoryName} directory.`)
} else {
fs.writeFile(
path.join(credentialsHomePath, credentialsFileName),
JSON.stringify({
/* eslint-disable @typescript-eslint/naming-convention */
client_id: accessTokenPayload.clientId,
access_token: accessTokenPayload.accessToken,
refresh_token: accessTokenPayload.refreshToken,
refresh_uri: accessTokenPayload.refreshUri,
expire_at: accessTokenPayload.expireAt,
/* eslint-enable @typescript-eslint/naming-convention */
}),
innerError => {
if (innerError) {
logger.error(`Could not write to ${credentialsFileName} file.`)
}
}
)
}
})
}
}
})
)
}
4 changes: 2 additions & 2 deletions app/ide-desktop/lib/client/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,8 @@ const AUTHENTICATION_API = {
*
* The backend doesn't have access to Electron's `localStorage` so we need to save access token
* to a file. Then the token will be used to sign cloud API requests. */
saveAccessToken: (accessToken: string | null) => {
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessToken)
saveAccessToken: (accessTokenPayload: SaveAccessTokenPayload | null) => {
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload)
},
}
electron.contextBridge.exposeInMainWorld(AUTHENTICATION_API_KEY, AUTHENTICATION_API)
44 changes: 39 additions & 5 deletions app/ide-desktop/lib/dashboard/src/authentication/cognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ export class Cognito {
}

/** Save the access token to a file for further reuse. */
saveAccessToken(accessToken: string | null) {
this.amplifyConfig.saveAccessToken?.(accessToken)
saveAccessToken(accessTokenPayload: SaveAccessTokenPayload | null) {
this.amplifyConfig.saveAccessToken?.(accessTokenPayload)
}

/** Return the current {@link UserSession}, or `None` if the user is not logged in.
Expand All @@ -194,7 +194,10 @@ export class Cognito {
async userSession() {
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
const amplifySession = currentSession.mapErr(intoCurrentSessionErrorType)
return amplifySession.map(parseUserSession).unwrapOr(null)

return amplifySession
.map(session => parseUserSession(session, this.amplifyConfig.userPoolWebClientId))
.unwrapOr(null)
}

/** Returns the associated organization ID of the current user, which is passed during signup,
Expand Down Expand Up @@ -262,6 +265,7 @@ export class Cognito {
const result = await results.Result.wrapAsync(async () => {
await amplify.Auth.signIn(username, password)
})

return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
}

Expand Down Expand Up @@ -367,19 +371,49 @@ export interface UserSession {
readonly email: string
/** User's access token, used to authenticate the user (e.g., when making API calls). */
readonly accessToken: string
/** User's refresh token, used to refresh the access token when it expires. */
readonly refreshToken: string
/** URL to refresh the access token. */
readonly refreshUrl: string
/** Time when the access token will expire, date in ISO format(UTC). */
readonly expireAt: string
/** The client ID of the user pool. */
readonly clientId: string
}

/** Parse a `CognitoUserSession` into a {@link UserSession}.
* @throws If the `email` field of the payload is not a string. */
function parseUserSession(session: cognito.CognitoUserSession): UserSession {
function parseUserSession(
session: cognito.CognitoUserSession,
clientId: string
): UserSession {
const payload: Readonly<Record<string, unknown>> = session.getIdToken().payload
const email = payload.email

/** The `email` field is mandatory, so we assert that it exists and is a string. */
if (typeof email !== 'string') {
throw new Error('Payload does not have an email field.')
} else {
const accessToken = session.getAccessToken().getJwtToken()
return { email, accessToken }

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const refreshUrl: string = session.getAccessToken().payload.iss
const expirationTimestamp = session.getAccessToken().getExpiration()

const expireAt = (() => {
const date = new Date(0)
date.setUTCSeconds(expirationTimestamp)
return date.toISOString()
})()

return {
email,
accessToken,
refreshUrl,
clientId,
refreshToken: session.getRefreshToken().getToken(),
expireAt,
}
}
}

Expand Down
Empty file.
7 changes: 4 additions & 3 deletions app/ide-desktop/lib/dashboard/src/authentication/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface AmplifyConfig {
readonly userPoolId: string
readonly userPoolWebClientId: string
readonly urlOpener: ((url: string, redirectUrl: string) => void) | null
readonly saveAccessToken: ((accessToken: string | null) => void) | null
readonly saveAccessToken: ((accessToken: SaveAccessTokenPayload | null) => void) | null
readonly domain: string
readonly scope: string[]
readonly redirectSignIn: string
Expand Down Expand Up @@ -125,6 +125,7 @@ export function initAuthService(authConfig: AuthConfig): AuthService | null {
amplifyConfig == null
? null
: new cognitoModule.Cognito(logger, supportsDeepLinks, amplifyConfig)

return cognito == null
? null
: { cognito, registerAuthEventListener: listen.registerAuthEventListener }
Expand All @@ -137,14 +138,14 @@ function loadAmplifyConfig(
navigate: (url: string) => void
): AmplifyConfig | null {
let urlOpener: ((url: string) => void) | null = null
let saveAccessToken: ((accessToken: string | null) => void) | null = null
let saveAccessToken: ((accessToken: SaveAccessTokenPayload | null) => void) | null = null
if ('authenticationApi' in window) {
// When running on desktop we want to have option to save access token to a file,
// so it can be reused later when issuing requests to the Cloud API.
//
// Note: Wrapping this function in an arrow function ensures that the current Authentication API
// is always used.
saveAccessToken = (accessToken: string | null) => {
saveAccessToken = (accessToken: SaveAccessTokenPayload | null) => {
window.authenticationApi.saveAccessToken(accessToken)
}
}
Expand Down
8 changes: 7 additions & 1 deletion app/ide-desktop/lib/dashboard/src/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,13 @@ export default function AuthProvider(props: AuthProviderProps) {
document.cookie = `logged_in=yes;max-age=34560000;domain=${parentDomain};samesite=strict;secure`

// Save access token so can it be reused by the backend.
cognito?.saveAccessToken(session.accessToken)
cognito?.saveAccessToken({
accessToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshToken: session.refreshToken,
refreshUri: session.refreshUrl,
})

// Execute the callback that should inform the Electron app that the user has logged in.
// This is done to transition the app from the authentication/dashboard view to the IDE.
Expand Down
2 changes: 1 addition & 1 deletion app/ide-desktop/lib/types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ interface AuthenticationApi {
* via a deep link. See `setDeepLinkHandler` for details. */
readonly setDeepLinkHandler: (callback: (url: string) => void) => void
/** Saves the access token to a file. */
readonly saveAccessToken: (accessToken: string | null) => void
readonly saveAccessToken: (accessToken: SaveAccessTokenPayload | null) => void
}

// =====================================
Expand Down
32 changes: 32 additions & 0 deletions app/ide-desktop/lib/types/ipc.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @file This file contains the types for the IPC events.
* The IPC events are used to communicate between the main and renderer processes.
* The events are defined in the main process and are listened to in the renderer process.
*/

/**
* Payload for the save-access-token event that saves an access token to a credentials file.
*/
interface SaveAccessTokenPayload {
/**
* The JWT token to save.
*/
readonly accessToken: string
/**
* The cognito app integration client id
*/
readonly clientId: string
/**
* The refresh token taken from initAuth flow.
*/
readonly refreshToken: string
/**
* The cognito url to refresh the token.
*/
readonly refreshUri: string
/**
* Time when the token will expire.
* This is a string representation of a date in ISO 8601 format (e.g. "2021-01-01T00:00:00Z").
*/
readonly expireAt: string
}

0 comments on commit 0801dbe

Please sign in to comment.