Skip to content

Commit 34b1aaa

Browse files
authWebview: Identity Center for CW (#3481)
* refactor: Consolidate sso validation code in to new file This moves+updates existing sso validation code in to its own module so that it can be reused elsewhere. The new file created is `src/auth/sso/validation.ts` Additionally, the existing code that uses this validation is updated to use the new changes. Signed-off-by: Nikolas Komonen <[email protected]> * authWebview: IAM Identity Center form - Can enter start url + region and it will attempt to use those to connect with code whisperer - Validation of start url (format + already exists) Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent 410bcaf commit 34b1aaa

File tree

8 files changed

+363
-21
lines changed

8 files changed

+363
-21
lines changed

src/auth/sso/validation.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import * as vscode from 'vscode'
6+
import { UnknownError } from '../../shared/errors'
7+
import { AuthType } from '../auth'
8+
import { isSsoConnection, SsoConnection, hasScopes } from '../connection'
9+
10+
export function validateSsoUrl(auth: AuthType, url: string, requiredScopes?: string[]) {
11+
const urlFormatError = validateSsoUrlFormat(url)
12+
if (urlFormatError) {
13+
return urlFormatError
14+
}
15+
16+
return validateIsNewSsoUrlAsync(auth, url, requiredScopes)
17+
}
18+
19+
export function validateSsoUrlFormat(url: string) {
20+
if (!url.match(/^(http|https):\/\//i)) {
21+
return 'URLs must start with http:// or https://. Example: https://d-xxxxxxxxxx.awsapps.com/start'
22+
}
23+
}
24+
25+
export async function validateIsNewSsoUrlAsync(
26+
auth: AuthType,
27+
url: string,
28+
requiredScopes?: string[]
29+
): Promise<string | undefined> {
30+
return auth.listConnections().then(conns => {
31+
return validateIsNewSsoUrl(url, requiredScopes, conns.filter(isSsoConnection))
32+
})
33+
}
34+
35+
export function validateIsNewSsoUrl(
36+
url: string,
37+
requiredScopes?: string[],
38+
existingSsoConns: SsoConnection[] = []
39+
): string | undefined {
40+
try {
41+
const uri = vscode.Uri.parse(url, true)
42+
const isSameAuthority = (a: vscode.Uri, b: vscode.Uri) =>
43+
a.authority.toLowerCase() === b.authority.toLowerCase()
44+
const oldConn = existingSsoConns.find(conn => isSameAuthority(vscode.Uri.parse(conn.startUrl), uri))
45+
46+
if (oldConn && (!requiredScopes || hasScopes(oldConn, requiredScopes))) {
47+
return 'A connection for this start URL already exists. Sign out before creating a new one.'
48+
}
49+
} catch (err) {
50+
return `URL is malformed: ${UnknownError.cast(err).message}`
51+
}
52+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<template>
2+
<div class="auth-form container-background border-common" id="identity-center-form">
3+
<div v-show="canShowAll">
4+
<FormTitle :isConnected="isConnected">IAM Identity Center</FormTitle>
5+
<div v-if="!isConnected">Successor to AWS Single Sign-on</div>
6+
7+
<div v-if="stage === 'START'">
8+
<div class="form-section">
9+
If your organization has provided you a CodeWhisperer license, sign in with your Identity Center
10+
access portal login page.
11+
<a>Read more.</a>
12+
</div>
13+
14+
<div class="form-section">
15+
<label class="input-title">Start URL</label>
16+
<label class="small-description">The Start URL</label>
17+
<input v-model="data.startUrl" type="text" :data-invalid="!!errors.startUrl" />
18+
<div class="small-description error-text">{{ errors.startUrl }}</div>
19+
</div>
20+
21+
<div class="form-section">
22+
<label class="input-title">Region</label>
23+
<label class="small-description">The Region</label>
24+
25+
<select v-on:click="getRegion()">
26+
<option v-if="!!data.region" :selected="true">{{ data.region }}</option>
27+
</select>
28+
</div>
29+
30+
<div class="form-section">
31+
<button v-on:click="signin()" :disabled="!canSubmit">Sign up or Sign in</button>
32+
</div>
33+
</div>
34+
35+
<div v-if="stage === 'WAITING_ON_USER'">
36+
<div class="form-section">
37+
<div>Follow instructions...</div>
38+
</div>
39+
</div>
40+
41+
<div v-if="stage === 'CONNECTED'">
42+
<div class="form-section">
43+
<div v-on:click="signout()" style="cursor: pointer; color: #75beff">Sign out</div>
44+
</div>
45+
</div>
46+
</div>
47+
</div>
48+
</template>
49+
<script lang="ts">
50+
import { PropType, defineComponent } from 'vue'
51+
import BaseAuthForm from './baseAuth.vue'
52+
import FormTitle from './formTitle.vue'
53+
import { WebviewClientFactory } from '../../../../webviews/client'
54+
import { AuthWebview } from '../show'
55+
import { AuthStatus } from './shared.vue'
56+
import { AuthFormId, authForms } from './types.vue'
57+
import { Region } from '../../../../shared/regions/endpoints'
58+
59+
const client = WebviewClientFactory.create<AuthWebview>()
60+
61+
export type IdentityCenterStage = 'START' | 'WAITING_ON_USER' | 'CONNECTED'
62+
63+
export default defineComponent({
64+
name: 'IdentityCenterForm',
65+
extends: BaseAuthForm,
66+
components: { FormTitle },
67+
props: {
68+
state: {
69+
type: Object as PropType<BaseIdentityCenterState>,
70+
required: true,
71+
},
72+
},
73+
data() {
74+
return {
75+
data: {
76+
startUrl: '',
77+
region: '' as Region['id'],
78+
},
79+
errors: {
80+
startUrl: '',
81+
},
82+
canSubmit: false,
83+
isConnected: false,
84+
85+
stage: 'START' as IdentityCenterStage,
86+
87+
canShowAll: false,
88+
}
89+
},
90+
91+
async created() {
92+
// Populate form if data already exists (triggers 'watch' functions)
93+
this.data.startUrl = this.state.getValue('startUrl')
94+
this.data.region = this.state.getValue('region')
95+
96+
await this.update()
97+
this.canShowAll = true
98+
},
99+
computed: {},
100+
methods: {
101+
async signin(): Promise<void> {
102+
await this.state.startIdentityCenterSetup()
103+
},
104+
async signout(): Promise<void> {
105+
await this.state.signout()
106+
},
107+
async update() {
108+
this.stage = await this.state.stage()
109+
this.isConnected = await this.state.isAuthConnected()
110+
this.emitAuthConnectionUpdated(this.state.id)
111+
},
112+
async getRegion() {
113+
const region = await this.state.getRegion()
114+
this.data.region = region.id
115+
},
116+
async updateData(key: IdentityCenterKey, value: string) {
117+
this.state.setValue(key, value)
118+
119+
if (key === 'startUrl') {
120+
this.errors.startUrl = await this.state.getStartUrlError()
121+
}
122+
123+
this.canSubmit = await this.state.canSubmit()
124+
},
125+
},
126+
watch: {
127+
'data.startUrl'(value: string) {
128+
this.updateData('startUrl', value)
129+
},
130+
'data.region'(value: string) {
131+
this.updateData('region', value)
132+
},
133+
},
134+
})
135+
136+
type IdentityCenterData = { startUrl: string; region: Region['id'] }
137+
type IdentityCenterKey = keyof IdentityCenterData
138+
139+
/**
140+
* Manages the state of Builder ID.
141+
*/
142+
abstract class BaseIdentityCenterState implements AuthStatus {
143+
protected _data: IdentityCenterData
144+
protected _stage: IdentityCenterStage = 'START'
145+
146+
constructor() {
147+
this._data = {
148+
startUrl: '',
149+
region: '',
150+
}
151+
}
152+
153+
abstract get id(): AuthFormId
154+
protected abstract _startIdentityCenterSetup(): Promise<void>
155+
abstract isAuthConnected(): Promise<boolean>
156+
157+
setValue(key: IdentityCenterKey, value: string) {
158+
this._data[key] = value
159+
}
160+
161+
getValue(key: IdentityCenterKey): string {
162+
return this._data[key]
163+
}
164+
165+
async startIdentityCenterSetup(): Promise<void> {
166+
this._stage = 'WAITING_ON_USER'
167+
return this._startIdentityCenterSetup()
168+
}
169+
170+
async stage(): Promise<IdentityCenterStage> {
171+
const isAuthConnected = await this.isAuthConnected()
172+
this._stage = isAuthConnected ? 'CONNECTED' : 'START'
173+
return this._stage
174+
}
175+
176+
async signout(): Promise<void> {
177+
return client.signoutIdentityCenter()
178+
}
179+
180+
async getRegion(): Promise<Region> {
181+
return client.getIdentityCenterRegion()
182+
}
183+
184+
async getStartUrlError() {
185+
const error = await client.getSsoUrlError(this._data.startUrl)
186+
return error ?? ''
187+
}
188+
189+
async canSubmit() {
190+
const allFieldsFilled = Object.values(this._data).every(val => !!val)
191+
const hasErrors = await this.getStartUrlError()
192+
return allFieldsFilled && !hasErrors
193+
}
194+
195+
protected async getSubmittableDataOrThrow(): Promise<IdentityCenterData> {
196+
return this._data as IdentityCenterData
197+
}
198+
}
199+
200+
export class CodeWhispererIdentityCenterState extends BaseIdentityCenterState {
201+
override get id(): AuthFormId {
202+
return authForms.IDENTITY_CENTER_CODE_WHISPERER
203+
}
204+
205+
protected override async _startIdentityCenterSetup(): Promise<void> {
206+
const data = await this.getSubmittableDataOrThrow()
207+
return client.startIdentityCenterSetup(data.startUrl, data.region)
208+
}
209+
210+
override async isAuthConnected(): Promise<boolean> {
211+
return client.isCodeWhispererIdentityCenterConnected()
212+
}
213+
}
214+
</script>
215+
<style>
216+
@import './sharedAuthForms.css';
217+
@import '../shared.css';
218+
219+
#identity-center-form {
220+
width: 250px;
221+
height: fit-content;
222+
}
223+
</style>

src/auth/ui/vue/authForms/shared.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script lang="ts">
22
import { CodeCatalystBuilderIdState, CodeWhispererBuilderIdState } from './manageBuilderId.vue'
33
import { CredentialsState } from './manageCredentials.vue'
4-
import authForms from './types.vue'
4+
import { authForms } from './types.vue'
5+
import { CodeWhispererIdentityCenterState } from './manageIdentityCenter.vue'
56
67
/**
78
* The state instance of all auth forms
@@ -10,6 +11,7 @@ const authFormsState = {
1011
[authForms.CREDENTIALS]: new CredentialsState(),
1112
[authForms.BUILDER_ID_CODE_WHISPERER]: new CodeWhispererBuilderIdState(),
1213
[authForms.BUILDER_ID_CODE_CATALYST]: new CodeCatalystBuilderIdState(),
14+
[authForms.IDENTITY_CENTER_CODE_WHISPERER]: new CodeWhispererIdentityCenterState(),
1315
} as const
1416
1517
export interface AuthStatus {

src/auth/ui/vue/authForms/types.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const authForms = {
33
CREDENTIALS: 'CREDENTIALS',
44
BUILDER_ID_CODE_WHISPERER: 'BUILDER_ID_CODE_WHISPERER',
55
BUILDER_ID_CODE_CATALYST: 'BUILDER_ID_CODE_CATALYST',
6+
IDENTITY_CENTER_CODE_WHISPERER: 'IDENTITY_CENTER_CODE_WHISPERER',
67
} as const
78
89
export type AuthFormId = (typeof authForms)[keyof typeof authForms]

src/auth/ui/vue/serviceItemContent/baseServiceItemContent.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,9 @@
1212
/* For testing purposes, before we have content to fill */
1313
min-height: 600px;
1414
}
15+
16+
.form-container {
17+
display: flex;
18+
flex-direction: row;
19+
gap: 20px;
20+
}

src/auth/ui/vue/serviceItemContent/codeWhispererContent.vue

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,33 @@
11
<template>
22
<div class="service-item-content-container border-common">
3-
<div>
3+
<div class="form-container">
44
<BuilderIdForm :state="builderIdState" @auth-connection-updated="onAuthConnectionUpdated"></BuilderIdForm>
5+
<IdentityCenterForm
6+
:state="identityCenterState"
7+
@auth-connection-updated="onAuthConnectionUpdated"
8+
></IdentityCenterForm>
59
</div>
610
</div>
711
</template>
812

913
<script lang="ts">
1014
import { defineComponent } from 'vue'
1115
import BuilderIdForm, { CodeWhispererBuilderIdState } from '../authForms/manageBuilderId.vue'
16+
import IdentityCenterForm, { CodeWhispererIdentityCenterState } from '../authForms/manageIdentityCenter.vue'
1217
import BaseServiceItemContent from './baseServiceItemContent.vue'
1318
import authFormsState, { AuthStatus } from '../authForms/shared.vue'
1419
1520
export default defineComponent({
1621
name: 'CodeWhispererContent',
17-
components: { BuilderIdForm },
22+
components: { BuilderIdForm, IdentityCenterForm },
1823
extends: BaseServiceItemContent,
1924
computed: {
2025
builderIdState(): CodeWhispererBuilderIdState {
2126
return authFormsState.BUILDER_ID_CODE_WHISPERER
2227
},
28+
identityCenterState(): CodeWhispererIdentityCenterState {
29+
return authFormsState.IDENTITY_CENTER_CODE_WHISPERER
30+
},
2331
},
2432
methods: {
2533
async onAuthConnectionUpdated() {
@@ -31,7 +39,11 @@ export default defineComponent({
3139
3240
export class CodeWhispererContentState implements AuthStatus {
3341
async isAuthConnected(): Promise<boolean> {
34-
return authFormsState.BUILDER_ID_CODE_WHISPERER.isAuthConnected()
42+
const result = await Promise.all([
43+
authFormsState.BUILDER_ID_CODE_WHISPERER.isAuthConnected(),
44+
authFormsState.IDENTITY_CENTER_CODE_WHISPERER.isAuthConnected(),
45+
])
46+
return result.filter(isConnected => isConnected).length > 0
3547
}
3648
}
3749
</script>

0 commit comments

Comments
 (0)