Skip to content

Commit e51a3a2

Browse files
authored
Merge pull request #4664 from aws/jpinkney-aws/new-auth
auth: Implement authorization grant with pkce
2 parents 34bd76b + 92643b7 commit e51a3a2

File tree

22 files changed

+2363
-102
lines changed

22 files changed

+2363
-102
lines changed

.eslintignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ src/shared/telemetry/clienttelemetry.d.ts
1111
src/codewhisperer/client/codewhispererclient.d.ts
1212
src/codewhisperer/client/codewhispereruserclient.d.ts
1313
src/amazonqFeatureDev/client/featuredevproxyclient.d.ts
14+
src/auth/sso/oidcclientpkce.d.ts
1415
src/testFixtures/**
1516
packages/core/src/shared/telemetry/clienttelemetry.d.ts
1617
packages/core/src/codewhisperer/client/codewhispererclient.d.ts
1718
packages/core/src/codewhisperer/client/codewhispereruserclient.d.ts
1819
packages/core/src/amazonqFeatureDev/client/featuredevproxyclient.d.ts
20+
packages/core/src/auth/sso/oidcclientpkce.d.ts
1921
packages/core/src/testFixtures/**

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ packages/core/src/shared/telemetry/clienttelemetry.d.ts
3131
packages/core/src/codewhisperer/client/codewhispererclient.d.ts
3232
packages/core/src/codewhisperer/client/codewhispereruserclient.d.ts
3333
packages/core/src/amazonqFeatureDev/client/featuredevproxyclient.d.ts
34+
packages/core/src/auth/sso/oidcclientpkce.d.ts
3435

3536
# Generated by tests
3637
packages/core/src/testFixtures/**/bin

packages/amazonq/src/extensionShared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export async function activateShared(context: vscode.ExtensionContext) {
5555

5656
await activateTelemetry(context, globals.awsContext, Settings.instance)
5757

58-
await initializeAuth(context, globals.awsContext, globals.loginManager, contextPrefix)
58+
await initializeAuth(context, globals.loginManager, contextPrefix, undefined)
5959

6060
await activateCodeWhisperer(extContext as ExtContext)
6161
await activateCWChat(context)

packages/core/scripts/build/generateServiceClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ void (async () => {
245245
serviceJsonPath: 'src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json',
246246
serviceName: 'FeatureDevProxyClient',
247247
},
248+
{
249+
serviceJsonPath: 'src/auth/sso/service-2.json',
250+
serviceName: 'OidcClientPKCE',
251+
},
248252
]
249253
await generateServiceClients(serviceClientDefinitions)
250254
})()

packages/core/src/auth/activation.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*/
55

66
import * as vscode from 'vscode'
7-
import { AwsContext } from '../shared/awsContext'
87
import { Auth } from './auth'
98
import { LoginManager } from './deprecated/loginManager'
109
import { fromString } from './providers/credentials'
@@ -14,12 +13,14 @@ import { isCloud9 } from '../shared/extensionUtilities'
1413
import { isInDevEnv } from '../shared/vscode/env'
1514
import { registerCommands, getShowManageConnections } from './ui/vue/show'
1615
import { isWeb } from '../common/webUtils'
16+
import { UriHandler } from '../shared/vscode/uriHandler'
17+
import { authenticationPath } from './sso/ssoAccessTokenProvider'
1718

1819
export async function initialize(
1920
extensionContext: vscode.ExtensionContext,
20-
awsContext: AwsContext,
2121
loginManager: LoginManager,
22-
contextPrefix: string
22+
contextPrefix: string,
23+
uriHandler?: UriHandler
2324
): Promise<void> {
2425
Auth.instance.onDidChangeActiveConnection(async conn => {
2526
// This logic needs to be moved to `Auth.useConnection` to correctly record `passive`
@@ -36,6 +37,11 @@ export async function initialize(
3637
extensionContext.subscriptions.push(getShowManageConnections())
3738

3839
await showManageConnectionsOnStartup()
40+
41+
uriHandler?.onPath(`/${authenticationPath}`, () => {
42+
// TODO emit telemetry
43+
getLogger().info('authenticated')
44+
})
3945
}
4046

4147
/**

packages/core/src/auth/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { SsoAccessTokenProvider } from './sso/ssoAccessTokenProvider'
1616
import { Timeout } from '../shared/utilities/timeoutUtils'
1717
import { errorCode, isAwsError, isNetworkError, ToolkitError, UnknownError } from '../shared/errors'
1818
import { getCache } from './sso/cache'
19-
import { createFactoryFunction, isNonNullable, Mutable } from '../shared/utilities/tsUtils'
19+
import { isNonNullable, Mutable } from '../shared/utilities/tsUtils'
2020
import { builderIdStartUrl, SsoToken, truncateStartUrl } from './sso/model'
2121
import { SsoClient } from './sso/clients'
2222
import { getLogger } from '../shared/logger'
@@ -141,7 +141,7 @@ export class Auth implements AuthService, ConnectionManager {
141141
private readonly store: ProfileStore,
142142
private readonly iamProfileProvider = CredentialsProviderManager.getInstance(),
143143
private readonly createSsoClient = SsoClient.create.bind(SsoClient),
144-
private readonly createSsoTokenProvider = createFactoryFunction(SsoAccessTokenProvider)
144+
private readonly createSsoTokenProvider = SsoAccessTokenProvider.create.bind(SsoAccessTokenProvider)
145145
) {}
146146

147147
#activeConnection: Mutable<StatefulConnection> | undefined

packages/core/src/auth/providers/sharedCredentialsProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export class SharedCredentialsProvider implements CredentialsProvider {
104104

105105
public async canAutoConnect(): Promise<boolean> {
106106
if (isSsoProfile(this.profile)) {
107-
const tokenProvider = new SsoAccessTokenProvider({
107+
const tokenProvider = SsoAccessTokenProvider.create({
108108
region: this.profile[SharedCredentialsKeys.SSO_REGION]!,
109109
startUrl: this.profile[SharedCredentialsKeys.SSO_START_URL]!,
110110
})
@@ -353,7 +353,7 @@ export class SharedCredentialsProvider implements CredentialsProvider {
353353
}
354354

355355
const region = ssoProfile.ssoRegion
356-
const tokenProvider = new SsoAccessTokenProvider({ ...ssoProfile, region })
356+
const tokenProvider = SsoAccessTokenProvider.create({ ...ssoProfile, region })
357357
const client = SsoClient.create(region, tokenProvider)
358358

359359
return async () => {

packages/core/src/auth/sso/clients.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ import { DevSettings } from '../../shared/settings'
3333
import { SdkError } from '@aws-sdk/types'
3434
import { HttpRequest, HttpResponse } from '@aws-sdk/protocol-http'
3535
import { StandardRetryStrategy, defaultRetryDecider } from '@aws-sdk/middleware-retry'
36+
import OidcClientPKCE from './oidcclientpkce'
37+
import { toSnakeCase } from '../../shared/utilities/textUtilities'
38+
import { Credentials, Service } from 'aws-sdk'
39+
import apiConfig = require('./service-2.json')
40+
import { ServiceOptions } from '../../shared/awsClientBuilder'
41+
import { ClientRegistration } from './model'
3642

3743
export class OidcClient {
3844
public constructor(private readonly client: SSOOIDC, private readonly clock: { Date: typeof Date }) {}
@@ -94,6 +100,79 @@ export class OidcClient {
94100
}
95101
}
96102

103+
export class OidcClientV2 {
104+
public constructor(private readonly region: string, private readonly clock: { Date: typeof Date }) {}
105+
106+
/**
107+
* TODO remove this when the real client gets created.
108+
*
109+
* Creating a new client is required because the old sdk seems to drop unknown parameters from requests
110+
*/
111+
private async createNewClient() {
112+
return (await globals.sdkClientBuilder.createAwsService(
113+
Service,
114+
{
115+
apiConfig,
116+
region: this.region,
117+
credentials: new Credentials({ accessKeyId: 'xxx', secretAccessKey: 'xxx' }),
118+
} as ServiceOptions,
119+
undefined
120+
)) as OidcClientPKCE
121+
}
122+
123+
public async registerClient(request: OidcClientPKCE.RegisterClientRequest): Promise<ClientRegistration> {
124+
const client = await this.createNewClient()
125+
const response = await client.makeUnauthenticatedRequest('registerClient', request).promise()
126+
assertHasProps(response, 'clientId', 'clientSecret', 'clientSecretExpiresAt')
127+
128+
return {
129+
scopes: request.scopes,
130+
clientId: response.clientId,
131+
clientSecret: response.clientSecret,
132+
expiresAt: new this.clock.Date(response.clientSecretExpiresAt * 1000),
133+
flow: 'auth code',
134+
}
135+
}
136+
137+
public async authorize(request: OidcClientPKCE.AuthorizeRequest) {
138+
// aws sdk doesn't convert to url params until right before you make the request, so we have to do
139+
// it manually ahead of time
140+
const params = toSnakeCase(request)
141+
const searchParams = new URLSearchParams(params).toString()
142+
return `https://oidc.${this.region}.amazonaws.com/authorize?${searchParams}`
143+
}
144+
145+
public async startDeviceAuthorization(request: StartDeviceAuthorizationRequest): Promise<{
146+
expiresAt: Date
147+
interval: number | undefined
148+
deviceCode: string
149+
userCode: string
150+
verificationUri: string
151+
}> {
152+
throw new Error('OidcClientV2 does not support device authorization')
153+
}
154+
155+
public async createToken(request: OidcClientPKCE.CreateTokenRequest): Promise<{
156+
accessToken: string
157+
expiresAt: Date
158+
tokenType?: string | undefined
159+
refreshToken?: string | undefined
160+
}> {
161+
const client = await this.createNewClient()
162+
const response = await client.makeUnauthenticatedRequest('createToken', request).promise()
163+
assertHasProps(response, 'accessToken', 'expiresIn')
164+
165+
return {
166+
...selectFrom(response, 'accessToken', 'refreshToken', 'tokenType'),
167+
expiresAt: new this.clock.Date(response.expiresIn * 1000 + this.clock.Date.now()),
168+
}
169+
}
170+
171+
public static create(region: string) {
172+
return new this(region, globals.clock)
173+
}
174+
}
175+
97176
type OmittedProps = 'accessToken' | 'nextToken'
98177
type ExtractOverload<T, U> = T extends {
99178
(...args: infer P1): infer R1

packages/core/src/auth/sso/model.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface SsoToken {
4545
readonly refreshToken?: string
4646
}
4747

48+
export type AuthenticationFlow = 'auth code'
49+
4850
export interface ClientRegistration {
4951
/**
5052
* Unique registration id.
@@ -65,6 +67,11 @@ export interface ClientRegistration {
6567
* Scope of the client registration. Applies to all tokens created using this registration.
6668
*/
6769
readonly scopes?: string[]
70+
71+
/**
72+
* The sso flow used to create this registration.
73+
*/
74+
readonly flow?: AuthenticationFlow
6875
}
6976

7077
export interface SsoProfile {
@@ -102,20 +109,6 @@ export async function openSsoPortalLink(startUrl: string, authorization: Authori
102109
return vscode.Uri.parse(`${authorization.verificationUri}?user_code=${authorization.userCode}`)
103110
}
104111

105-
async function openSsoUrl() {
106-
const ssoLoginUrl = makeConfirmCodeUrl(authorization)
107-
const didOpenUrl = await vscode.env.openExternal(ssoLoginUrl)
108-
109-
if (!didOpenUrl) {
110-
throw new ToolkitError(`User clicked 'Copy' or 'Cancel' during the Trusted Domain popup`, {
111-
code: trustedDomainCancellation,
112-
name: trustedDomainCancellation,
113-
cancelled: true,
114-
})
115-
}
116-
return didOpenUrl
117-
}
118-
119112
async function showLoginNotification() {
120113
const name = startUrl === builderIdStartUrl ? localizedText.builderId() : localizedText.iamIdentityCenterFull()
121114
// C9 doesn't support `detail` field with modals so we need to put it all in the `title`
@@ -134,7 +127,7 @@ export async function openSsoPortalLink(startUrl: string, authorization: Authori
134127
const resp = await vscode.window.showInformationMessage(title, { modal: true, detail }, proceedToBrowser)
135128
switch (resp) {
136129
case proceedToBrowser:
137-
return openSsoUrl()
130+
return openSsoUrl(makeConfirmCodeUrl(authorization))
138131
case localizedText.help:
139132
await tryOpenHelpUrl(ssoAuthHelpUrl)
140133
continue
@@ -147,8 +140,25 @@ export async function openSsoPortalLink(startUrl: string, authorization: Authori
147140
return showLoginNotification()
148141
}
149142

143+
export async function openSsoUrl(location: vscode.Uri) {
144+
const didOpenUrl = await vscode.env.openExternal(location)
145+
146+
if (!didOpenUrl) {
147+
throw new ToolkitError(`User clicked 'Copy' or 'Cancel' during the Trusted Domain popup`, {
148+
code: trustedDomainCancellation,
149+
name: trustedDomainCancellation,
150+
cancelled: true,
151+
})
152+
}
153+
return didOpenUrl
154+
}
155+
150156
// Most SSO 'expirables' are fairly long lived, so a one minute buffer is plenty.
151157
const expirationBufferMs = 60000
152158
export function isExpired(expirable: { expiresAt: Date }): boolean {
153159
return globals.clock.Date.now() + expirationBufferMs >= expirable.expiresAt.getTime()
154160
}
161+
162+
export function isDeprecatedAuth(registration: ClientRegistration): boolean {
163+
return registration.flow === undefined || registration.flow !== 'auth code'
164+
}

0 commit comments

Comments
 (0)