Skip to content

Commit

Permalink
Merge pull request #18651 from desktop/enable-path-based-accounts
Browse files Browse the repository at this point in the history
Support multiple accounts for remote urls that differ in path
  • Loading branch information
niik committed May 23, 2024
2 parents 9918c16 + 9df8f33 commit a4ca0d6
Show file tree
Hide file tree
Showing 9 changed files with 173 additions and 176 deletions.
97 changes: 15 additions & 82 deletions app/src/lib/generic-git-auth.ts
Original file line number Diff line number Diff line change
@@ -1,107 +1,40 @@
import { parseRemote } from './remote-parsing'
import { getKeyForEndpoint } from './auth'
import { TokenStore } from './stores/token-store'

const tryParseURLHostname = (url: string) => {
try {
return new URL(url).hostname
} catch {
return undefined
}
}

/** Get the hostname to use for the given remote. */
export function getGenericHostname(remoteURL: string): string {
const parsed = parseRemote(remoteURL)
if (parsed) {
if (parsed.protocol === 'https') {
return tryParseURLHostname(remoteURL) ?? parsed.hostname
}

return parsed.hostname
}

return tryParseURLHostname(remoteURL) ?? remoteURL
}

export const genericGitAuthUsernameKeyPrefix = 'genericGitAuth/username/'

function getKeyForUsername(hostname: string): string {
return `${genericGitAuthUsernameKeyPrefix}${hostname}`
function getKeyForUsername(endpoint: string): string {
return `${genericGitAuthUsernameKeyPrefix}${endpoint}`
}

/** Get the username for the host. */
export function getGenericUsername(hostname: string): string | null {
const key = getKeyForUsername(hostname)
export function getGenericUsername(endpoint: string): string | null {
const key = getKeyForUsername(endpoint)
return localStorage.getItem(key)
}

/** Set the username for the host. */
export function setGenericUsername(hostname: string, username: string) {
const key = getKeyForUsername(hostname)
export function setGenericUsername(endpoint: string, username: string) {
const key = getKeyForUsername(endpoint)
return localStorage.setItem(key, username)
}

/** Set the password for the username and host. */
export function setGenericPassword(
hostname: string,
endpoint: string,
username: string,
password: string
): Promise<void> {
const key = getKeyForEndpoint(hostname)
const key = getKeyForEndpoint(endpoint)
return TokenStore.setItem(key, username, password)
}

/** Delete a generic credential */
export function deleteGenericCredential(hostname: string, username: string) {
localStorage.removeItem(getKeyForUsername(hostname))
return TokenStore.deleteItem(getKeyForEndpoint(hostname), username)
}

/**
* Migrate generic git credentials from the old format which could include
* a path to the new format which only includes the hostname.
*/
export async function removePathFromGenericGitAuthCreds() {
try {
for (const key of Object.keys(localStorage)) {
if (key.startsWith(genericGitAuthUsernameKeyPrefix)) {
const oldHostname = key.substring(
genericGitAuthUsernameKeyPrefix.length
)
const slashIx = oldHostname.indexOf('/')
if (slashIx === -1) {
continue
}

const newHostname = oldHostname.substring(0, slashIx)
log.info(`Migrating credentials ${oldHostname}${newHostname}`)

// Don't overwrite existing credentials
if (getGenericUsername(newHostname)) {
continue
}

const username = getGenericUsername(oldHostname)
/** Get the password for the given username and host. */
export const getGenericPassword = (endpoint: string, username: string) =>
TokenStore.getItem(getKeyForEndpoint(endpoint), username)

if (!username) {
continue
}

const password = await TokenStore.getItem(
getKeyForEndpoint(oldHostname),
username
)

if (password) {
setGenericUsername(newHostname, username)
setGenericPassword(newHostname, username, password)

deleteGenericCredential(oldHostname, username)
}
}
}
} catch {
log.error('Failed to remove path from generic git credentials')
}
/** Delete a generic credential */
export function deleteGenericCredential(endpoint: string, username: string) {
localStorage.removeItem(getKeyForUsername(endpoint))
return TokenStore.deleteItem(getKeyForEndpoint(endpoint), username)
}
6 changes: 1 addition & 5 deletions app/src/lib/stores/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ import {
IMultiCommitOperationProgress,
} from '../../models/progress'
import { Popup, PopupType } from '../../models/popup'
import { IGitAccount } from '../../models/git-account'
import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor'
import { getAppPath } from '../../ui/lib/app-proxy'
import {
Expand Down Expand Up @@ -135,7 +134,6 @@ import {
import { assertNever, fatalError, forceUnwrap } from '../fatal-error'

import { formatCommitMessage } from '../format-commit-message'
import { removePathFromGenericGitAuthCreds } from '../generic-git-auth'
import { getAccountForRepository } from '../get-account-for-repository'
import {
abortMerge,
Expand Down Expand Up @@ -605,8 +603,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
this.getResolvedExternalEditor
)

removePathFromGenericGitAuthCreds()

// We're considering flipping the default value and have new users
// start off with repository indicators disabled. As such we'll start
// persisting the current default to localstorage right away so we
Expand Down Expand Up @@ -6046,7 +6042,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
fn: (repository: Repository) => Promise<T>
): Promise<T> {
let updatedRepository = repository
const account: IGitAccount | null = getAccountForRepository(
const account: Account | null = getAccountForRepository(
this.accounts,
updatedRepository
)
Expand Down
171 changes: 104 additions & 67 deletions app/src/lib/trampoline/trampoline-askpass-handler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { getKeyForEndpoint } from '../auth'
import {
getSSHKeyPassphrase,
keepSSHKeyPassphraseToStore,
} from '../ssh/ssh-key-passphrase'
import { AccountsStore, TokenStore } from '../stores'
import { AccountsStore } from '../stores'
import {
ITrampolineCommand,
TrampolineCommandHandler,
Expand All @@ -17,7 +16,7 @@ import {
import { removePendingSSHSecretToStore } from '../ssh/ssh-secret-storage'
import { getHTMLURL } from '../api'
import {
getGenericHostname,
getGenericPassword,
getGenericUsername,
setGenericPassword,
setGenericUsername,
Expand All @@ -30,6 +29,7 @@ import {
setMostRecentGenericGitCredential,
} from './trampoline-environment'
import { IGitAccount } from '../../models/git-account'
import memoizeOne from 'memoize-one'

async function handleSSHHostAuthenticity(
prompt: string
Expand Down Expand Up @@ -177,98 +177,135 @@ const handleAskPassUserPassword = async (
) => {
const info = (msg: string) => log.info(`askPassHandler: ${msg}`)
const debug = (msg: string) => log.debug(`askPassHandler: ${msg}`)
const warn = (msg: string) => log.warn(`askPassHandler: ${msg}`)

const { trampolineToken } = command
const hostname = getGenericHostname(remoteUrl)
const account = await findAccount(trampolineToken, accountsStore, hostname)
const parsedUrl = new URL(remoteUrl)
const endpoint = urlWithoutCredentials(remoteUrl)
const account = await findAccount(trampolineToken, accountsStore, remoteUrl)

if (!account) {
if (getHasRejectedCredentialsForEndpoint(trampolineToken, hostname)) {
debug(`not requesting credentials for ${hostname}`)
return undefined
}

if (getIsBackgroundTaskEnvironment(trampolineToken)) {
debug('background task environment, skipping prompt')
return undefined
}
if (account) {
const accountKind = account instanceof Account ? 'account' : 'generic'
debug(`${accountKind} ${kind.toLowerCase()} for ${remoteUrl} found`)
return kind === 'Username' ? account.login : account.token
}

info(`no account found for ${hostname}`)
if (getHasRejectedCredentialsForEndpoint(trampolineToken, endpoint)) {
debug(`not requesting credentials for ${remoteUrl}`)
return undefined
}

if (hostname === 'github.com') {
// We don't want to show a generic auth prompt for GitHub.com and we
// don't have a good way to turn the sign in flow into a promise. More
// specifically we can create a promise that resolves when the GH sign in
// flow completes but we don't have a way to have the promise reject if
// the user cancels.
return undefined
}
if (getIsBackgroundTaskEnvironment(trampolineToken)) {
debug('background task environment, skipping prompt')
return undefined
}

const { username, password } =
await trampolineUIHelper.promptForGenericGitAuthentication(hostname)
info(`no account found for ${remoteUrl}`)

if (username.length > 0 && password.length > 0) {
setGenericUsername(hostname, username)
await setGenericPassword(hostname, username, password)
if (parsedUrl.hostname === 'github.com') {
// We don't want to show a generic auth prompt for GitHub.com and we
// don't have a good way to turn the sign in flow into a promise. More
// specifically we can create a promise that resolves when the GH sign in
// flow completes but we don't have a way to have the promise reject if
// the user cancels.
return undefined
}

info(`acquired generic credentials for ${hostname}`)
const { username, password } =
await trampolineUIHelper.promptForGenericGitAuthentication(
remoteUrl,
parsedUrl.username === '' ? undefined : parsedUrl.username
)

return kind === 'Username' ? username : password
} else {
info('user cancelled generic git authentication')
setHasRejectedCredentialsForEndpoint(trampolineToken, hostname)
}
if (!username || !password) {
info('user cancelled generic git authentication')
setHasRejectedCredentialsForEndpoint(trampolineToken, endpoint)

return undefined
} else {
const accountKind = account instanceof Account ? 'account' : 'generic'
if (kind === 'Username') {
debug(`${accountKind} username for ${hostname} found`)
return account.login
} else if (kind === 'Password') {
const token =
account instanceof Account && account.token.length > 0
? account.token
: await TokenStore.getItem(
getKeyForEndpoint(account.endpoint),
account.login
)

if (token) {
debug(`${accountKind} password for ${hostname} found`)
} else {
// We have a username but no password, that warrants a warning
warn(`${accountKind} password for ${hostname} missing`)
}

return token ?? undefined
}
}

return undefined
// Git will ordinarily prompt us twice, first for the username and then
// for the password. For the second prompt the url will contain the
// username. For example:
// Prompt 1: Username for 'https://example.com':
// < user enters username >
// Prompt 2: Password for 'https://[email protected]':
//
// So when we get a prompt that doesn't include the username we know that
// it's the first prompt. This matters because users can include the
// username in the remote url in which case Git won't even prompt us for
// the username. For example:
// https://[email protected]/org/repo/_git/repo
//
// If we're getting prompted for password directly with the username we
// don't want to store the username association, only the password.
if (parsedUrl.username === '') {
setGenericUsername(endpoint, username)
}

await setGenericPassword(endpoint, username, password)

info(`acquired generic credentials for ${remoteUrl}`)

return kind === 'Username' ? username : password
}

/**
* When we're asked for credentials we're typically first asked for the username
* immediately followed by the password. We memoize the getGenericPassword call
* such that we only call it once per endpoint/login pair. Since we include the
* trampoline token in the invalidation key we'll only call it once per
* trampoline session.
*/
const memoizedGetGenericPassword = memoizeOne(
(_trampolineToken: string, endpoint: string, login: string) =>
getGenericPassword(endpoint, login)
)

async function findAccount(
trampolineToken: string,
accountsStore: AccountsStore,
hostname: string
remoteUrl: string
): Promise<IGitAccount | undefined> {
const accounts = await accountsStore.getAll()
const parsedUrl = new URL(remoteUrl)
const endpoint = urlWithoutCredentials(remoteUrl)
const account = accounts.find(
a => new URL(getHTMLURL(a.endpoint)).hostname === hostname
a => new URL(getHTMLURL(a.endpoint)).origin === parsedUrl.origin
)

if (account) {
return account
}

const login = getGenericUsername(hostname)
const login =
parsedUrl.username === ''
? getGenericUsername(endpoint)
: parsedUrl.username

if (hostname && login) {
setMostRecentGenericGitCredential(trampolineToken, hostname, login)
return { login, endpoint: hostname }
if (!login) {
return undefined
}

return undefined
const token = await memoizedGetGenericPassword(
trampolineToken,
endpoint,
login
)

if (!token) {
// We have a username but no password, that warrants a warning
log.warn(`askPassHandler: generic password for ${remoteUrl} missing`)
return undefined
}

setMostRecentGenericGitCredential(trampolineToken, endpoint, login)

return { login, endpoint, token }
}

function urlWithoutCredentials(remoteUrl: string): string {
const url = new URL(remoteUrl)
url.username = ''
url.password = ''
return url.toString()
}
Loading

0 comments on commit a4ca0d6

Please sign in to comment.