Compare commits

...

2 Commits

Author SHA1 Message Date
Zakaria 238bb1a3d5 feat(frontend): hosted sign-in and OAuth settings for Cognito app clients
CI / checks (pull_request) Successful in 26m7s
2026-10-24 11:17:42 -04:00
Zakaria d411f632c9 feat(api): show a user pool's Lambda triggers 2026-10-24 02:07:18 -04:00
17 changed files with 629 additions and 3 deletions
@@ -23,6 +23,7 @@ import {
ListUserPoolClientsCommand, ListUserPoolClientsCommand,
ListUserPoolsCommand, ListUserPoolsCommand,
ListUsersCommand, ListUsersCommand,
UpdateUserPoolClientCommand,
type CognitoIdentityProviderClient, type CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider' } from '@aws-sdk/client-cognito-identity-provider'
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
@@ -125,9 +126,31 @@ describe('AwsUserPoolsAdapter', () => {
mfaConfiguration: 'OFF', mfaConfiguration: 'OFF',
deletionProtection: 'INACTIVE', deletionProtection: 'INACTIVE',
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}], appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
lambdaTriggers: [],
}) })
}) })
test('lists the pool\'s Lambda triggers in sign-in order, with the trigger version when set', async () => {
const arn = (name: string) => `arn:aws:lambda:us-east-1:000000000000:function:${name}`
const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, LambdaConfig: {
PreTokenGeneration: arn('claims'),
PreTokenGenerationConfig: {LambdaArn: arn('claims'), LambdaVersion: 'V2_0'},
PreSignUp: `${arn('signup')}:live`,
CustomEmailSender: {LambdaArn: arn('mailer'), LambdaVersion: 'V1_0'},
KMSKeyID: 'arn:aws:kms:us-east-1:000000000000:key/1',
}}}
return {}
})
const resource = await new AwsUserPoolsAdapter(client).get(POOL_ID)
expect(resource?.metadata.lambdaTriggers).toEqual([
{trigger: 'PreSignUp', label: 'Pre sign-up', functionArn: `${arn('signup')}:live`, functionName: 'signup', version: null},
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: arn('claims'), functionName: 'claims', version: 'V2_0'},
{trigger: 'CustomEmailSender', label: 'Custom email sender', functionArn: arn('mailer'), functionName: 'mailer', version: 'V1_0'},
])
})
test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => { test('counts users itself, since the runtime leaves EstimatedNumberOfUsers at 0', async () => {
const {client} = stubCognito((command) => { const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}}
@@ -387,3 +410,63 @@ describe('AwsUserPoolsAdapter user actions and groups', () => {
.toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'}) .toEqual({outcome: 'challenge', challengeName: 'NEW_PASSWORD_REQUIRED'})
}) })
}) })
describe('app client OAuth settings', () => {
const CLIENT_ID = '40808d60e188483d876bbbf646'
const stored = {
UserPoolId: POOL_ID, ClientId: CLIENT_ID, ClientName: 'notes-demo-web',
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
SupportedIdentityProviders: ['COGNITO'],
}
function clientStub() {
return stubCognito((command) => {
if (command instanceof DescribeUserPoolClientCommand) return {UserPoolClient: stored}
if (command instanceof UpdateUserPoolClientCommand) return {UserPoolClient: {...stored, ...command.input}}
return {}
})
}
test('reads a client with no OAuth set up', async () => {
const settings = await new AwsUserPoolsAdapter(clientStub().client).describeUserPoolClient(POOL_ID, CLIENT_ID)
expect(settings).toEqual({
clientId: CLIENT_ID, clientName: 'notes-demo-web', hasSecret: false,
explicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO'],
})
})
test('turns on the code flow and sends the client\'s other settings back unchanged', async () => {
const {client, sent} = clientStub()
const settings = await new AwsUserPoolsAdapter(client).updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {
callbackUrls: ['http://localhost:4600/api/auth/callback', ' http://localhost:4600/api/auth/callback '],
logoutUrls: ['http://localhost:4600/'],
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
})
const update = sent.find((command) => command instanceof UpdateUserPoolClientCommand) as UpdateUserPoolClientCommand
expect(update.input).toMatchObject({
ClientName: 'notes-demo-web', ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
PreventUserExistenceErrors: 'ENABLED', RefreshTokenValidity: 30, EnableTokenRevocation: true,
CallbackURLs: ['http://localhost:4600/api/auth/callback'], LogoutURLs: ['http://localhost:4600/'],
AllowedOAuthFlows: ['code'], AllowedOAuthScopes: ['openid', 'email'], AllowedOAuthFlowsUserPoolClient: true,
SupportedIdentityProviders: ['COGNITO'],
})
expect(settings.oauthFlows).toEqual(['code'])
})
test('applies Cognito\'s rules before calling it', async () => {
const {client, sent} = clientStub()
const adapter = new AwsUserPoolsAdapter(client)
const valid = {callbackUrls: ['https://app.example/callback'], logoutUrls: [], oauthFlows: ['code' as const], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['http://app.example/callback']})).rejects.toThrow(/https/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: ['https://app.example/#done']})).rejects.toThrow(/fragment/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, callbackUrls: []})).rejects.toThrow(/callback URL/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthScopes: []})).rejects.toThrow(/scope/)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['code', 'client_credentials']})).rejects.toThrow(ValidationError)
await expect(adapter.updateUserPoolClientOAuth(POOL_ID, CLIENT_ID, {...valid, oauthFlows: ['client_credentials']})).rejects.toThrow(/secret/)
expect(sent.some((command) => command instanceof UpdateUserPoolClientCommand)).toBe(false)
})
})
@@ -24,11 +24,13 @@ import {
ListUserPoolClientsCommand, ListUserPoolClientsCommand,
ListUserPoolsCommand, ListUserPoolsCommand,
ListUsersCommand, ListUsersCommand,
UpdateUserPoolClientCommand,
type AttributeType, type AttributeType,
type CognitoIdentityProviderClient, type CognitoIdentityProviderClient,
type GroupType, type GroupType,
type PasswordPolicyType, type PasswordPolicyType,
type UserPoolClientDescription, type UserPoolClientDescription,
type UserPoolClientType,
type UserPoolDescriptionType, type UserPoolDescriptionType,
type UserPoolType, type UserPoolType,
type UserType, type UserType,
@@ -38,6 +40,7 @@ import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors'
import { import {
USER_POOL_GROUP_NAME_PATTERN, USER_POOL_GROUP_NAME_PATTERN,
USER_POOL_NAME_PATTERN, USER_POOL_NAME_PATTERN,
USER_POOL_OAUTH_FLOWS,
USER_POOL_PASSWORD_POLICIES, USER_POOL_PASSWORD_POLICIES,
USER_POOL_SIGN_IN, USER_POOL_SIGN_IN,
awsUserPoolsSchema, awsUserPoolsSchema,
@@ -49,7 +52,10 @@ import type {
CreateUserPoolGroupInput, CreateUserPoolGroupInput,
ResourceQuery, ResourceQuery,
ServiceSchema, ServiceSchema,
UpdateUserPoolClientOAuthInput,
UserPoolClientSettings,
UserPoolGroup, UserPoolGroup,
UserPoolOAuthFlow,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolUserAction, UserPoolUserAction,
@@ -285,6 +291,54 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter {
} }
} }
async describeUserPoolClient(poolId: string, clientId: string): Promise<UserPoolClientSettings> {
return toClientSettings(await this.fetchClient(poolId, clientId))
}
/**
* Changes the settings the hosted login and OAuth endpoints use. UpdateUserPoolClient
* resets every setting it is not given, so the client's other settings are sent back as they are.
*/
async updateUserPoolClientOAuth(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
const settings = validateOAuthSettings(input)
const current = await this.fetchClient(poolId, clientId)
if (settings.oauthFlows.includes('client_credentials') && !current.ClientSecret) {
throw new ValidationError('The client_credentials flow needs an app client with a secret')
}
const res = await this.cognito.send(new UpdateUserPoolClientCommand({
UserPoolId: poolId,
ClientId: clientId,
ClientName: current.ClientName,
RefreshTokenValidity: current.RefreshTokenValidity,
AccessTokenValidity: current.AccessTokenValidity,
IdTokenValidity: current.IdTokenValidity,
TokenValidityUnits: current.TokenValidityUnits,
ReadAttributes: current.ReadAttributes,
WriteAttributes: current.WriteAttributes,
ExplicitAuthFlows: current.ExplicitAuthFlows,
DefaultRedirectURI: current.DefaultRedirectURI && settings.callbackUrls.includes(current.DefaultRedirectURI) ? current.DefaultRedirectURI : undefined,
AnalyticsConfiguration: current.AnalyticsConfiguration,
PreventUserExistenceErrors: current.PreventUserExistenceErrors,
EnableTokenRevocation: current.EnableTokenRevocation,
EnablePropagateAdditionalUserContextData: current.EnablePropagateAdditionalUserContextData,
AuthSessionValidity: current.AuthSessionValidity,
RefreshTokenRotation: current.RefreshTokenRotation,
CallbackURLs: settings.callbackUrls,
LogoutURLs: settings.logoutUrls,
AllowedOAuthFlows: settings.oauthFlows,
AllowedOAuthScopes: settings.oauthScopes,
AllowedOAuthFlowsUserPoolClient: settings.oauthFlows.length > 0,
SupportedIdentityProviders: settings.identityProviders,
}))
return toClientSettings(res.UserPoolClient ?? current)
}
private async fetchClient(poolId: string, clientId: string): Promise<UserPoolClientType> {
const res = await this.cognito.send(new DescribeUserPoolClientCommand({UserPoolId: poolId, ClientId: requiredString(clientId, 'clientId')}))
if (!res.UserPoolClient) throw new RuntimeError(`Cognito returned no app client ${clientId}`)
return res.UserPoolClient
}
private async listGroupsForUser(poolId: string, username: string): Promise<string[]> { private async listGroupsForUser(poolId: string, username: string): Promise<string[]> {
return listGroupNamesForUser(this.cognito, poolId, username) return listGroupNamesForUser(this.cognito, poolId, username)
} }
@@ -460,10 +514,103 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us
mfaConfiguration: pool.MfaConfiguration ?? null, mfaConfiguration: pool.MfaConfiguration ?? null,
deletionProtection: pool.DeletionProtection ?? null, deletionProtection: pool.DeletionProtection ?? null,
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})), appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
lambdaTriggers: lambdaTriggersOf(pool.LambdaConfig),
}, },
} }
} }
// Labels as the AWS console names them, in the order a sign-up and sign-in reach them.
const LAMBDA_TRIGGERS = [
['PreSignUp', 'Pre sign-up'],
['UserMigration', 'User migration'],
['CustomMessage', 'Custom message'],
['PostConfirmation', 'Post confirmation'],
['PreAuthentication', 'Pre authentication'],
['DefineAuthChallenge', 'Define auth challenge'],
['CreateAuthChallenge', 'Create auth challenge'],
['VerifyAuthChallengeResponse', 'Verify auth challenge response'],
['PostAuthentication', 'Post authentication'],
['PreTokenGeneration', 'Pre token generation'],
['CustomEmailSender', 'Custom email sender'],
['CustomSMSSender', 'Custom SMS sender'],
] as const
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] {
if (!config) return []
return LAMBDA_TRIGGERS.flatMap(([trigger, label]) => {
// PreTokenGenerationConfig carries the trigger version and supersedes the older PreTokenGeneration field.
const versioned = trigger === 'PreTokenGeneration' ? config.PreTokenGenerationConfig
: trigger === 'CustomEmailSender' ? config.CustomEmailSender
: trigger === 'CustomSMSSender' ? config.CustomSMSSender
: undefined
const functionArn = versioned?.LambdaArn ?? config[trigger]
if (typeof functionArn !== 'string' || !functionArn) return []
return [{trigger, label, functionArn, functionName: functionArn.split(':')[6] ?? functionArn, version: versioned?.LambdaVersion ?? null}]
})
}
function toClientSettings(client: UserPoolClientType): UserPoolClientSettings {
return {
clientId: client.ClientId ?? '',
clientName: client.ClientName ?? '',
hasSecret: Boolean(client.ClientSecret),
explicitAuthFlows: client.ExplicitAuthFlows ?? [],
callbackUrls: client.CallbackURLs ?? [],
logoutUrls: client.LogoutURLs ?? [],
oauthFlows: client.AllowedOAuthFlowsUserPoolClient ? (client.AllowedOAuthFlows ?? []).filter(isOAuthFlow) : [],
oauthScopes: client.AllowedOAuthScopes ?? [],
identityProviders: client.SupportedIdentityProviders ?? [],
}
}
function isOAuthFlow(value: string): value is UserPoolOAuthFlow {
return (USER_POOL_OAUTH_FLOWS as readonly string[]).includes(value)
}
/** Cognito's own rules for these settings, checked here so the form can say what is wrong. */
function validateOAuthSettings(input: UpdateUserPoolClientOAuthInput): UpdateUserPoolClientOAuthInput {
const callbackUrls = urlList(input?.callbackUrls, 'Callback URL')
const logoutUrls = urlList(input.logoutUrls, 'Sign-out URL')
const oauthFlows = stringList(input.oauthFlows, 'oauthFlows')
const unknown = oauthFlows.find((flow) => !isOAuthFlow(flow))
if (unknown) throw new ValidationError(`Unknown OAuth flow ${unknown}`)
if (oauthFlows.includes('client_credentials') && oauthFlows.length > 1) {
throw new ValidationError('client_credentials cannot be combined with the code or implicit flows')
}
if ((oauthFlows.includes('code') || oauthFlows.includes('implicit')) && callbackUrls.length === 0) {
throw new ValidationError('Add at least one callback URL for the code or implicit flow')
}
const oauthScopes = stringList(input.oauthScopes, 'oauthScopes')
if (oauthFlows.length > 0 && oauthScopes.length === 0) throw new ValidationError('Choose at least one OAuth scope')
const identityProviders = stringList(input.identityProviders, 'identityProviders')
return {callbackUrls, logoutUrls, oauthFlows: oauthFlows as UserPoolOAuthFlow[], oauthScopes, identityProviders}
}
function stringList(value: unknown, field: string): string[] {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new ValidationError(`${field} must be a list of strings`)
return [...new Set(value.map((entry: string) => entry.trim()).filter(Boolean))]
}
// Cognito accepts https URLs, and http only for localhost; fragments are never allowed.
function urlList(value: unknown, label: string): string[] {
return stringList(value, label).map((entry) => {
let url: URL
try {
url = new URL(entry)
} catch {
throw new ValidationError(`${label} ${entry} is not a valid URL`)
}
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
throw new ValidationError(`${label} ${entry} must use https (http is allowed only for localhost)`)
}
if (url.hash || entry.includes('#')) throw new ValidationError(`${label} ${entry} must not contain a fragment`)
return entry
})
}
function toUserItem( function toUserItem(
username: string | undefined, username: string | undefined,
attributes: AttributeType[] | undefined, attributes: AttributeType[] | undefined,
+27
View File
@@ -178,6 +178,8 @@ export type UserPoolActionName =
| 'createGroup' | 'createGroup'
| 'deleteGroup' | 'deleteGroup'
| 'testSignIn' | 'testSignIn'
| 'describeClient'
| 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {
@@ -453,6 +455,29 @@ export interface UserPoolSignInTestInput {
} }
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ /** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
/** The app client settings that decide how the hosted login and OAuth endpoints treat it. */
export interface UserPoolClientSettings {
clientId: string
clientName: string
hasSecret: boolean
explicitAuthFlows: string[]
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export interface UpdateUserPoolClientOAuthInput {
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export type UserPoolSignInTestResult = export type UserPoolSignInTestResult =
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>} | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
| {outcome: 'challenge'; challengeName: string} | {outcome: 'challenge'; challengeName: string}
@@ -970,6 +995,8 @@ export interface CloudServiceAdapter {
createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup> createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise<UserPoolGroup>
deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void> deleteUserPoolGroup?(poolId: string, groupName: string): Promise<void>
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */ /** Clears the provider's locally captured email inbox, if it exposes one. */
clearEmailInbox?(): Promise<void> clearEmailInbox?(): Promise<void>
// AppConfig nested resources, keyed by the ids the provider needs. Inspect // AppConfig nested resources, keyed by the ids the provider needs. Inspect
@@ -81,6 +81,8 @@ export function awsUserPoolsSchema(): ServiceSchema {
{name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true}, {name: 'createGroup', label: 'Create group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true}, {name: 'deleteGroup', label: 'Delete group', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true}, {name: 'testSignIn', label: 'Test sign-in', enabled: true, status: 'available', runtimeRequired: true},
{name: 'describeClient', label: 'Show app client settings', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateClientOAuth', label: 'Change hosted sign-in and OAuth settings', enabled: true, status: 'available', runtimeRequired: true},
], ],
}, },
filters: userPoolFilters, filters: userPoolFilters,
@@ -92,6 +94,10 @@ export function awsUserPoolsSchema(): ServiceSchema {
} }
} }
export const USER_POOL_OAUTH_FLOWS = ['code', 'implicit', 'client_credentials'] as const
/** Scopes every pool offers; resource servers can add their own. */
export const USER_POOL_STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin'] as const
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null { export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsUserPoolsSchema() : null return cloud === 'aws' ? awsUserPoolsSchema() : null
} }
+2
View File
@@ -54,6 +54,8 @@ const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudService
createGroup: 'createUserPoolGroup', createGroup: 'createUserPoolGroup',
deleteGroup: 'deleteUserPoolGroup', deleteGroup: 'deleteUserPoolGroup',
testSignIn: 'testUserPoolSignIn', testSignIn: 'testUserPoolSignIn',
describeClient: 'describeUserPoolClient',
updateClientOAuth: 'updateUserPoolClientOAuth',
} }
const registry = createCloudAdapterRegistry() const registry = createCloudAdapterRegistry()
+22
View File
@@ -747,6 +747,28 @@ describe('cloud schema routes', () => {
]) ])
}) })
test('reads and updates an app client\'s OAuth settings', async () => {
const settings = {clientId: 'c1', clientName: 'web', hasSecret: false, explicitAuthFlows: [], callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'userpools',
schema: awsUserPoolsSchema,
describeUserPoolClient: async () => settings,
updateUserPoolClientOAuth: async (poolId, clientId, input) => {
calls.push([poolId, clientId, input])
return {...settings, ...input}
},
})])
const base = '/api/clouds/aws/services/userpools/resources/us-east-1_x/clients/c1'
const input = {callbackUrls: ['http://localhost:4600/cb'], logoutUrls: [], oauthFlows: ['code'], oauthScopes: ['openid'], identityProviders: ['COGNITO']}
expect(await (await app.request(base)).json()).toEqual(settings)
const updated = await app.request(`${base}/oauth`, {method: 'PUT', headers: {'content-type': 'application/json'}, body: JSON.stringify(input)})
expect(updated.status).toBe(200)
expect((await updated.json()).oauthFlows).toEqual(['code'])
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
})
test('clears the SES mailbox through the email adapter', async () => { test('clears the SES mailbox through the email adapter', async () => {
let cleared = false let cleared = false
const app = appWithRoutes([mockAdapter('aws', { const app = appWithRoutes([mockAdapter('aws', {
+14
View File
@@ -447,6 +447,20 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json()))) return withRuntime(c, async () => c.json(await svc(c).testUserPoolSignIn(cloud, c.req.param('id'), await c.req.json())))
}) })
app.get('/:cloud/services/userpools/resources/:id/clients/:clientId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeUserPoolClient(cloud, c.req.param('id'), c.req.param('clientId'))))
})
app.put('/:cloud/services/userpools/resources/:id/clients/:clientId/oauth', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
})
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -81,6 +81,8 @@ import type {
UserPoolGroup, UserPoolGroup,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
} from '../cloud-spi/types' } from '../cloud-spi/types'
import type { import type {
@@ -831,6 +833,18 @@ export class CloudProxyService {
return adapter.testUserPoolSignIn(poolId, input) return adapter.testUserPoolSignIn(poolId, input)
} }
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
return adapter.describeUserPoolClient(poolId, clientId)
}
async updateUserPoolClientOAuth(cloud: CloudProvider, poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> {
const adapter = this.requireAdapter(cloud, 'userpools')
if (!adapter.updateUserPoolClientOAuth) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
return adapter.updateUserPoolClientOAuth(poolId, clientId, input)
}
async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> { async listKubernetesNodegroups(cloud: CloudProvider, clusterId: string): Promise<KubernetesNodegroup[]> {
const adapter = this.requireAdapter(cloud, 'k8s') const adapter = this.requireAdapter(cloud, 'k8s')
if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`) if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`)
+18
View File
@@ -139,6 +139,10 @@ export const apiEndpointKeys = {
delete: "clouds.services.userpools.groups.delete", delete: "clouds.services.userpools.groups.delete",
}, },
signInTest: "clouds.services.userpools.sign-in-test", signInTest: "clouds.services.userpools.sign-in-test",
client: {
get: "clouds.services.userpools.clients.get",
updateOAuth: "clouds.services.userpools.clients.update-oauth",
},
}, },
k8s: { k8s: {
nodegroups: { nodegroups: {
@@ -882,6 +886,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", method: "POST",
}, },
], ],
[
apiEndpointKeys.clouds.userpools.client.get,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId",
method: "GET",
},
],
[
apiEndpointKeys.clouds.userpools.client.updateOAuth,
{
path: "/clouds/:cloud/services/userpools/resources/:id/clients/:clientId/oauth",
method: "PUT",
},
],
[ [
apiEndpointKeys.clouds.k8s.nodegroups.list, apiEndpointKeys.clouds.k8s.nodegroups.list,
{ {
@@ -31,6 +31,8 @@ import type {
UserPoolGroup, UserPoolGroup,
UserPoolSignInTestInput, UserPoolSignInTestInput,
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
LogsInsightsQueryInput, LogsInsightsQueryInput,
LogsInsightsQueryResult, LogsInsightsQueryResult,
@@ -1474,6 +1476,34 @@ export async function testUserPoolSignIn(
return res.data; return res.data;
} }
export async function describeUserPoolClient(
cloud: CloudProvider,
poolId: string,
clientId: string,
signal?: AbortSignal,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings>(
apiEndpointKeys.clouds.userpools.client.get,
requestOptions(cloud, "userpools", { signal }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function updateUserPoolClientOAuth(
cloud: CloudProvider,
poolId: string,
clientId: string,
input: UpdateUserPoolClientOAuthInput,
): Promise<UserPoolClientSettings> {
const res = await apiClient.call<UserPoolClientSettings, UpdateUserPoolClientOAuthInput>(
apiEndpointKeys.clouds.userpools.client.updateOAuth,
requestOptions(cloud, "userpools", { body: input }),
{ cloud, id: poolId, clientId },
);
return res.data;
}
export async function putFlatItem( export async function putFlatItem(
cloud: CloudProvider, cloud: CloudProvider,
service: CloudServiceType, service: CloudServiceType,
@@ -0,0 +1,145 @@
import {X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeUserPoolClient, updateUserPoolClientOAuth} from '@/api/cloudProxyClient'
import {useAccountId} from '@/lib/accountStore'
import type {UserPoolClientSettings, UserPoolOAuthFlow} from '@/types/resource'
type Props = {
poolId: string
clientId: string
runtimeReachable: boolean
onClose: () => void
}
const FLOWS: {flow: UserPoolOAuthFlow; label: string; hint: string}[] = [
{flow: 'code', label: 'Authorization code', hint: 'Recommended for web and mobile apps, with PKCE.'},
{flow: 'implicit', label: 'Implicit', hint: 'Legacy: tokens come back in the URL.'},
{flow: 'client_credentials', label: 'Client credentials', hint: 'Machine-to-machine. Needs a client secret.'},
]
const STANDARD_SCOPES = ['openid', 'email', 'phone', 'profile', 'aws.cognito.signin.user.admin']
const COGNITO = 'COGNITO'
type Draft = {callbackUrls: string; logoutUrls: string; oauthFlows: UserPoolOAuthFlow[]; oauthScopes: string[]; identityProviders: string[]}
export function CognitoAppClientSettings({poolId, clientId, runtimeReachable, onClose}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const clientKey = ['cognito-client', accountId, poolId, clientId]
const [draft, setDraft] = useState<Draft | null>(null)
const [notice, setNotice] = useState('')
const clientQuery = useQuery({
queryKey: clientKey,
queryFn: ({signal}) => describeUserPoolClient('aws', poolId, clientId, signal),
enabled: runtimeReachable,
})
const client = clientQuery.data
useEffect(() => {
if (client) setDraft(draftFrom(client))
}, [client])
const saveMutation = useMutation({
mutationFn: (value: Draft) => updateUserPoolClientOAuth('aws', poolId, clientId, {
callbackUrls: lines(value.callbackUrls),
logoutUrls: lines(value.logoutUrls),
oauthFlows: value.oauthFlows,
oauthScopes: value.oauthScopes,
identityProviders: value.identityProviders,
}),
onSuccess: (saved) => {
queryClient.setQueryData(clientKey, saved)
setNotice(saved.oauthFlows.includes('code') && saved.identityProviders.includes(COGNITO)
? 'Saved. Apps using this client can now send users to the hosted login page.'
: 'Saved.')
},
})
const disabled = !runtimeReachable || saveMutation.isPending
const scopes = draft ? [...STANDARD_SCOPES, ...draft.oauthScopes.filter((scope) => !STANDARD_SCOPES.includes(scope))] : STANDARD_SCOPES
const otherProviders = draft?.identityProviders.filter((provider) => provider !== COGNITO) ?? []
const changed = Boolean(client && draft && JSON.stringify(normalized(draft)) !== JSON.stringify(normalized(draftFrom(client))))
const update = (patch: Partial<Draft>) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) }
const toggle = <T extends string>(list: T[], value: T, on: boolean) => on ? [...list.filter((entry) => entry !== value), value] : list.filter((entry) => entry !== value)
const error = clientQuery.error ?? saveMutation.error
return <section className="cognito-user" aria-label={`App client ${client?.clientName || clientId}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">App client</p><strong>{client?.clientName || clientId}</strong></div>
<button className="icon-btn" type="button" title="Close" aria-label="Close app client settings" onClick={onClose}><X size={14}/></button>
</div>
{clientQuery.isLoading && <p className="muted padded">Loading app client</p>}
{client && draft && <form className="cognito-user-grid" aria-label="Hosted sign-in settings" onSubmit={(event) => { event.preventDefault(); saveMutation.mutate(draft) }}>
<dl className="cognito-facts">
<dt>Client ID</dt><dd className="mono">{client.clientId}</dd>
<dt>Client secret</dt><dd>{client.hasSecret ? 'Yes' : 'No (public client)'}</dd>
<dt>Sign-in flows</dt><dd>{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => <code key={flow} className="cognito-flow">{flow.replace(/^ALLOW_/, '')}</code>) : '—'}</dd>
</dl>
<div>
<p className="eyebrow">Hosted sign-in</p>
<label className="cognito-check">
<input type="checkbox" checked={draft.identityProviders.includes(COGNITO)} disabled={disabled} onChange={(event) => update({identityProviders: toggle(draft.identityProviders, COGNITO, event.target.checked)})}/>
Hosted login page for this pool's users
</label>
{otherProviders.length > 0 && <p className="muted compact-text">Also signs in with: {otherProviders.map((provider) => <code key={provider}>{provider}</code>)}</p>}
<p className="eyebrow">OAuth flows</p>
{FLOWS.map(({flow, label, hint}) => (
<label key={flow} className="cognito-check" title={hint}>
<input type="checkbox" checked={draft.oauthFlows.includes(flow)} disabled={disabled || (flow === 'client_credentials' && !client.hasSecret)} onChange={(event) => update({oauthFlows: toggle(draft.oauthFlows, flow, event.target.checked)})}/>
{label} <small className="muted">{hint}</small>
</label>
))}
</div>
<div>
<p className="eyebrow">Scopes</p>
{scopes.map((scope) => (
<label key={scope} className="cognito-check">
<input type="checkbox" checked={draft.oauthScopes.includes(scope)} disabled={disabled} onChange={(event) => update({oauthScopes: toggle(draft.oauthScopes, scope, event.target.checked)})}/>
<code>{scope}</code>
</label>
))}
</div>
<div className="cognito-client-urls">
<label className="dynamic-field"><span>Callback URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.callbackUrls} disabled={disabled} placeholder="http://localhost:3000/api/auth/callback" onChange={(event) => update({callbackUrls: event.target.value})}/>
<small className="muted">One per line. Where Cognito sends users back with a code. https, or http for localhost.</small>
</label>
<label className="dynamic-field"><span>Sign-out URLs</span>
<textarea className="textarea code-textarea small" rows={3} value={draft.logoutUrls} disabled={disabled} placeholder="http://localhost:3000/" onChange={(event) => update({logoutUrls: event.target.value})}/>
<small className="muted">One per line. Allowed <code>logout_uri</code> values after sign-out.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{saveMutation.isPending ? 'Saving' : 'Save settings'}</button></div>
</div>
</form>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
function draftFrom(client: UserPoolClientSettings): Draft {
return {
callbackUrls: client.callbackUrls.join('\n'),
logoutUrls: client.logoutUrls.join('\n'),
oauthFlows: client.oauthFlows,
oauthScopes: client.oauthScopes,
identityProviders: client.identityProviders,
}
}
function normalized(draft: Draft) {
return {
callbackUrls: lines(draft.callbackUrls),
logoutUrls: lines(draft.logoutUrls),
oauthFlows: [...draft.oauthFlows].sort(),
oauthScopes: [...draft.oauthScopes].sort(),
identityProviders: [...draft.identityProviders].sort(),
}
}
function lines(value: string): string[] {
return value.split('\n').map((line) => line.trim()).filter(Boolean)
}
@@ -0,0 +1,31 @@
import {ScrollText, Zap} from 'lucide-react'
import type {CloudResource} from '@/types/resource'
type LambdaTrigger = {trigger: string; label: string; functionArn: string; functionName: string; version: string | null}
export function CognitoTriggersSection({pool}: {pool?: CloudResource}) {
const triggers = triggersOf(pool)
return <>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Lambda triggers</p><span className="muted">Functions Cognito runs during sign-up, sign-in and token issue</span></div>
</div>
{pool && triggers.length === 0 && <p className="muted padded">This pool runs no Lambda triggers.</p>}
{triggers.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Trigger</th><th>Function</th><th>Version</th><th/></tr></thead><tbody>{triggers.map((trigger) => (
<tr key={trigger.trigger}>
<td>{trigger.label}</td>
<td><code title={trigger.functionArn}>{trigger.functionName}</code></td>
<td>{trigger.version ? <code>{trigger.version}</code> : '—'}</td>
<td className="cognito-row-actions">
<a className="button" href={`/cloud-explorer/aws/serverless?resource=${encodeURIComponent(trigger.functionName)}`} aria-label={`Open function ${trigger.functionName}`}><Zap size={13}/> Function</a>
<a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${trigger.functionName}`)}`} aria-label={`Open logs for ${trigger.functionName}`}><ScrollText size={13}/> Logs</a>
</td>
</tr>
))}</tbody></table></div>}
</>
}
function triggersOf(pool?: CloudResource): LambdaTrigger[] {
const triggers = pool?.metadata.lambdaTriggers
if (!Array.isArray(triggers)) return []
return triggers.filter((trigger): trigger is LambdaTrigger => typeof trigger?.functionName === 'string' && typeof trigger?.label === 'string')
}
@@ -17,6 +17,8 @@ const api = vi.hoisted(() => ({
createUserPoolGroup: vi.fn(), createUserPoolGroup: vi.fn(),
deleteUserPoolGroup: vi.fn(), deleteUserPoolGroup: vi.fn(),
testUserPoolSignIn: vi.fn(), testUserPoolSignIn: vi.fn(),
describeUserPoolClient: vi.fn(),
updateUserPoolClientOAuth: vi.fn(),
})) }))
vi.mock('@/api/cloudProxyClient', () => api) vi.mock('@/api/cloudProxyClient', () => api)
@@ -64,6 +66,50 @@ test('shows the app client id and the pool users', async () => {
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything()) expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
}) })
test('lists the pool\'s Lambda triggers with links to the function and its logs', async () => {
api.getCloudResource.mockResolvedValue({...pool('email'), metadata: {...pool('email').metadata, lambdaTriggers: [
{trigger: 'PreTokenGeneration', label: 'Pre token generation', functionArn: 'arn:aws:lambda:us-east-1:000000000000:function:notes-demo-pre-token', functionName: 'notes-demo-pre-token', version: null},
]}})
renderPanel()
expect(await screen.findByText('Pre token generation')).toBeInTheDocument()
expect(screen.getByRole('link', {name: 'Open function notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless?resource=notes-demo-pre-token')
expect(screen.getByRole('link', {name: 'Open logs for notes-demo-pre-token'})).toHaveAttribute('href', '/cloud-explorer/aws/logs?resource=%2Faws%2Flambda%2Fnotes-demo-pre-token')
})
test('says when a pool has no Lambda triggers', async () => {
api.getCloudResource.mockResolvedValue(pool('email'))
renderPanel()
expect(await screen.findByText('This pool runs no Lambda triggers.')).toBeInTheDocument()
})
test('turns on hosted sign-in for an app client', async () => {
const user = userEvent.setup()
const settings = {clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client', hasSecret: false, explicitAuthFlows: ['ALLOW_REFRESH_TOKEN_AUTH'],
callbackUrls: [], logoutUrls: [], oauthFlows: [], oauthScopes: [], identityProviders: ['COGNITO']}
api.getCloudResource.mockResolvedValue(pool('email'))
api.describeUserPoolClient.mockResolvedValue(settings)
api.updateUserPoolClientOAuth.mockImplementation(async (_cloud, _pool, _client, input) => ({...settings, ...input}))
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Manage app client orders-users-client'}))
const form = await screen.findByRole('form', {name: 'Hosted sign-in settings'})
expect(within(form).getByRole('checkbox', {name: /Client credentials/})).toBeDisabled()
await user.click(within(form).getByRole('checkbox', {name: /Authorization code/}))
await user.click(within(form).getByRole('checkbox', {name: 'openid'}))
await user.click(within(form).getByRole('checkbox', {name: 'email'}))
await user.type(within(form).getByLabelText(/Callback URLs/), 'http://localhost:4600/api/auth/callback')
await user.type(within(form).getByLabelText(/Sign-out URLs/), 'http://localhost:4600/')
await user.click(within(form).getByRole('button', {name: 'Save settings'}))
expect(api.updateUserPoolClientOAuth).toHaveBeenCalledWith('aws', summary.id, 'a8e5c7d38d3b4a1a97c9d822de', {
callbackUrls: ['http://localhost:4600/api/auth/callback'], logoutUrls: ['http://localhost:4600/'],
oauthFlows: ['code'], oauthScopes: ['openid', 'email'], identityProviders: ['COGNITO'],
})
expect(await screen.findByText(/can now send users to the hosted login page/)).toBeInTheDocument()
})
test('creates an email user who can sign in immediately', async () => { test('creates an email user who can sign in immediately', async () => {
const user = userEvent.setup() const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('email')) api.getCloudResource.mockResolvedValue(pool('email'))
@@ -2,8 +2,10 @@ import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient'
import {CognitoAppClientSettings} from '@/components/CognitoAppClientSettings'
import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' import {CognitoGroupsSection} from '@/components/CognitoGroupsSection'
import {CognitoSignInTest} from '@/components/CognitoSignInTest' import {CognitoSignInTest} from '@/components/CognitoSignInTest'
import {CognitoTriggersSection} from '@/components/CognitoTriggersSection'
import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CognitoUserDetail} from '@/components/CognitoUserDetail'
import {CopyValueButton} from '@/components/CopyValueButton' import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore' import {useAccountId} from '@/lib/accountStore'
@@ -24,6 +26,7 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [permanent, setPermanent] = useState(true) const [permanent, setPermanent] = useState(true)
const [openUser, setOpenUser] = useState<string | null>(null) const [openUser, setOpenUser] = useState<string | null>(null)
const [openClient, setOpenClient] = useState<string | null>(null)
const usersKey = ['cognito-users', accountId, resource?.id] const usersKey = ['cognito-users', accountId, resource?.id]
const groupsKey = ['cognito-groups', accountId, resource?.id] const groupsKey = ['cognito-groups', accountId, resource?.id]
@@ -93,9 +96,16 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div> <div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
</div> </div>
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>} {poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => ( {appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th><th/></tr></thead><tbody>{appClients.map((client) => (
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr> <tr key={client.clientId}>
<td>{client.clientName || '—'}</td>
<td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td>
<td className="cognito-row-actions"><button className="button" type="button" aria-expanded={openClient === client.clientId} aria-label={`Manage app client ${client.clientName || client.clientId}`} onClick={() => setOpenClient(openClient === client.clientId ? null : client.clientId)}>Manage</button></td>
</tr>
))}</tbody></table></div>} ))}</tbody></table></div>}
{openClient && <CognitoAppClientSettings key={openClient} poolId={resource.id} clientId={openClient} runtimeReachable={runtimeReachable} onClose={() => setOpenClient(null)}/>}
<CognitoTriggersSection pool={poolQuery.data ?? undefined}/>
<CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/> <CognitoSignInTest poolId={resource.id} appClients={appClients} loginLabel={loginLabel} runtimeReachable={runtimeReachable}/>
+15
View File
@@ -4395,6 +4395,21 @@ pre,
margin-bottom: 8px; margin-bottom: 8px;
} }
.cognito-check small { flex-basis: 100%; padding-left: 24px; margin-top: -4px; }
.cognito-check:has(small) { flex-wrap: wrap; row-gap: 0; }
.cognito-flow { display: inline-block; margin: 0 6px 4px 0; }
.cognito-client-urls {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
align-items: start;
}
.cognito-client-urls > div { align-self: end; }
.delete-impact { .delete-impact {
color: var(--status-warn); color: var(--status-warn);
font-size: 13px; font-size: 13px;
+16
View File
@@ -302,6 +302,22 @@ export interface UserPoolSignInTestInput {
password: string password: string
} }
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
export interface UserPoolClientSettings {
clientId: string
clientName: string
hasSecret: boolean
explicitAuthFlows: string[]
callbackUrls: string[]
logoutUrls: string[]
oauthFlows: UserPoolOAuthFlow[]
oauthScopes: string[]
identityProviders: string[]
}
export type UpdateUserPoolClientOAuthInput = Pick<UserPoolClientSettings, 'callbackUrls' | 'logoutUrls' | 'oauthFlows' | 'oauthScopes' | 'identityProviders'>
export type UserPoolSignInTestResult = export type UserPoolSignInTestResult =
| {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>} | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record<string, unknown>; accessToken: Record<string, unknown>}
| {outcome: 'challenge'; challengeName: string} | {outcome: 'challenge'; challengeName: string}
+1 -1
View File
@@ -27,7 +27,7 @@ export type KubernetesActionName =
| 'createFargateProfile' | 'createFargateProfile'
| 'deleteFargateProfile' | 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {