From d411f632c9cecba3c521322d039e2bd16bb37610 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 24 Oct 2026 02:07:18 -0400 Subject: [PATCH 1/2] feat(api): show a user pool's Lambda triggers --- .../adapter-aws/AwsUserPoolsAdapter.test.ts | 22 +++++++++++++ .../src/adapter-aws/AwsUserPoolsAdapter.ts | 33 +++++++++++++++++++ .../src/components/CognitoTriggersSection.tsx | 31 +++++++++++++++++ .../src/components/CognitoUsersPanel.test.tsx | 18 ++++++++++ .../src/components/CognitoUsersPanel.tsx | 3 ++ 5 files changed, 107 insertions(+) create mode 100644 packages/frontend/src/components/CognitoTriggersSection.tsx diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts index 7a4ce10..4307642 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -125,9 +125,31 @@ describe('AwsUserPoolsAdapter', () => { mfaConfiguration: 'OFF', deletionProtection: 'INACTIVE', 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 () => { const {client} = stubCognito((command) => { if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, EstimatedNumberOfUsers: 0}} diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts index 3e9e893..e3354d2 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -460,10 +460,43 @@ function toResource(pool: UserPoolType, clients: UserPoolClientDescription[], us mfaConfiguration: pool.MfaConfiguration ?? null, deletionProtection: pool.DeletionProtection ?? null, 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 toUserItem( username: string | undefined, attributes: AttributeType[] | undefined, diff --git a/packages/frontend/src/components/CognitoTriggersSection.tsx b/packages/frontend/src/components/CognitoTriggersSection.tsx new file mode 100644 index 0000000..1a966f8 --- /dev/null +++ b/packages/frontend/src/components/CognitoTriggersSection.tsx @@ -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 <> +
+

Lambda triggers

Functions Cognito runs during sign-up, sign-in and token issue
+
+ {pool && triggers.length === 0 &&

This pool runs no Lambda triggers.

} + {triggers.length > 0 &&
{triggers.map((trigger) => ( + + + + + + + ))}
TriggerFunctionVersion
{trigger.label}{trigger.functionName}{trigger.version ? {trigger.version} : '—'} + Function + Logs +
} + +} + +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') +} diff --git a/packages/frontend/src/components/CognitoUsersPanel.test.tsx b/packages/frontend/src/components/CognitoUsersPanel.test.tsx index 6ecbbaf..b70aa6b 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.test.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.test.tsx @@ -64,6 +64,24 @@ test('shows the app client id and the pool users', async () => { 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('creates an email user who can sign in immediately', async () => { const user = userEvent.setup() api.getCloudResource.mockResolvedValue(pool('email')) diff --git a/packages/frontend/src/components/CognitoUsersPanel.tsx b/packages/frontend/src/components/CognitoUsersPanel.tsx index 1ef2c70..7697698 100644 --- a/packages/frontend/src/components/CognitoUsersPanel.tsx +++ b/packages/frontend/src/components/CognitoUsersPanel.tsx @@ -4,6 +4,7 @@ import {useEffect, useState} from 'react' import {deleteFlatItem, getCloudResource, listFlatItems, listUserPoolGroups, putFlatItem} from '@/api/cloudProxyClient' import {CognitoGroupsSection} from '@/components/CognitoGroupsSection' import {CognitoSignInTest} from '@/components/CognitoSignInTest' +import {CognitoTriggersSection} from '@/components/CognitoTriggersSection' import {CognitoUserDetail} from '@/components/CognitoUserDetail' import {CopyValueButton} from '@/components/CopyValueButton' import {useAccountId} from '@/lib/accountStore' @@ -97,6 +98,8 @@ export function CognitoUsersPanel({resource, runtimeReachable}: Props) { {client.clientName || '—'}{client.clientId} ))}} + + From 238bb1a3d5934bd0010f547fa51c0bf15f191e08 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 24 Oct 2026 11:16:46 -0400 Subject: [PATCH 2/2] feat(frontend): hosted sign-in and OAuth settings for Cognito app clients --- .../adapter-aws/AwsUserPoolsAdapter.test.ts | 61 ++++++++ .../src/adapter-aws/AwsUserPoolsAdapter.ts | 114 ++++++++++++++ packages/api/src/cloud-spi/types.ts | 27 ++++ packages/api/src/cloud-spi/userPoolsSchema.ts | 6 + packages/api/src/cloudProxy.test.ts | 2 + packages/api/src/routes/clouds.test.ts | 22 +++ packages/api/src/routes/clouds.ts | 14 ++ packages/api/src/service/CloudProxyService.ts | 14 ++ packages/frontend/src/api/api.ts | 18 +++ packages/frontend/src/api/cloudProxyClient.ts | 30 ++++ .../components/CognitoAppClientSettings.tsx | 145 ++++++++++++++++++ .../src/components/CognitoUsersPanel.test.tsx | 28 ++++ .../src/components/CognitoUsersPanel.tsx | 11 +- packages/frontend/src/index.css | 15 ++ packages/frontend/src/types/resource.ts | 16 ++ packages/frontend/src/types/schema.ts | 2 +- 16 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/components/CognitoAppClientSettings.tsx diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts index 4307642..0b4a46b 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.test.ts @@ -23,6 +23,7 @@ import { ListUserPoolClientsCommand, ListUserPoolsCommand, ListUsersCommand, + UpdateUserPoolClientCommand, type CognitoIdentityProviderClient, } from '@aws-sdk/client-cognito-identity-provider' import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter' @@ -409,3 +410,63 @@ describe('AwsUserPoolsAdapter user actions and groups', () => { .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) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts index e3354d2..1e1ce1e 100644 --- a/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts +++ b/packages/api/src/adapter-aws/AwsUserPoolsAdapter.ts @@ -24,11 +24,13 @@ import { ListUserPoolClientsCommand, ListUserPoolsCommand, ListUsersCommand, + UpdateUserPoolClientCommand, type AttributeType, type CognitoIdentityProviderClient, type GroupType, type PasswordPolicyType, type UserPoolClientDescription, + type UserPoolClientType, type UserPoolDescriptionType, type UserPoolType, type UserType, @@ -38,6 +40,7 @@ import {ConflictError, RuntimeError, ValidationError} from '../cloud-spi/errors' import { USER_POOL_GROUP_NAME_PATTERN, USER_POOL_NAME_PATTERN, + USER_POOL_OAUTH_FLOWS, USER_POOL_PASSWORD_POLICIES, USER_POOL_SIGN_IN, awsUserPoolsSchema, @@ -49,7 +52,10 @@ import type { CreateUserPoolGroupInput, ResourceQuery, ServiceSchema, + UpdateUserPoolClientOAuthInput, + UserPoolClientSettings, UserPoolGroup, + UserPoolOAuthFlow, UserPoolSignInTestInput, UserPoolSignInTestResult, UserPoolUserAction, @@ -285,6 +291,54 @@ export class AwsUserPoolsAdapter implements CloudServiceAdapter { } } + async describeUserPoolClient(poolId: string, clientId: string): Promise { + 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 { + 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 { + 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 { return listGroupNamesForUser(this.cognito, poolId, username) } @@ -497,6 +551,66 @@ function lambdaTriggersOf(config: UserPoolType['LambdaConfig']): LambdaTrigger[] }) } +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( username: string | undefined, attributes: AttributeType[] | undefined, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index b5c6bfd..248ff80 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -178,6 +178,8 @@ export type UserPoolActionName = | 'createGroup' | 'deleteGroup' | 'testSignIn' + | 'describeClient' + | 'updateClientOAuth' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -453,6 +455,29 @@ export interface UserPoolSignInTestInput { } /** 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 = | {outcome: 'signed-in'; expiresIn: number | null; idToken: Record; accessToken: Record} | {outcome: 'challenge'; challengeName: string} @@ -970,6 +995,8 @@ export interface CloudServiceAdapter { createUserPoolGroup?(poolId: string, input: CreateUserPoolGroupInput): Promise deleteUserPoolGroup?(poolId: string, groupName: string): Promise testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise + describeUserPoolClient?(poolId: string, clientId: string): Promise + updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise /** Clears the provider's locally captured email inbox, if it exposes one. */ clearEmailInbox?(): Promise // AppConfig nested resources, keyed by the ids the provider needs. Inspect diff --git a/packages/api/src/cloud-spi/userPoolsSchema.ts b/packages/api/src/cloud-spi/userPoolsSchema.ts index 4889416..92e0a92 100644 --- a/packages/api/src/cloud-spi/userPoolsSchema.ts +++ b/packages/api/src/cloud-spi/userPoolsSchema.ts @@ -81,6 +81,8 @@ export function awsUserPoolsSchema(): ServiceSchema { {name: 'createGroup', label: 'Create 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: '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, @@ -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 { return cloud === 'aws' ? awsUserPoolsSchema() : null } diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 2ab4a44..6eb2e96 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -54,6 +54,8 @@ const METHOD_FOR_USER_POOL_ACTION: Record { ]) }) + 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 () => { let cleared = false const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 3031510..a00d2f2 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -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()))) }) + 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) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index d1c0653..bb28558 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -81,6 +81,8 @@ import type { UserPoolGroup, UserPoolSignInTestInput, UserPoolSignInTestResult, + UserPoolClientSettings, + UpdateUserPoolClientOAuthInput, UserPoolUserAction, } from '../cloud-spi/types' import type { @@ -831,6 +833,18 @@ export class CloudProxyService { return adapter.testUserPoolSignIn(poolId, input) } + async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise { + 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 { + 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 { const adapter = this.requireAdapter(cloud, 'k8s') if (!adapter.listKubernetesNodegroups) throw new NotSupportedError(`Nodegroups are not supported for ${cloud}/k8s`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index c7673ce..7ef7420 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -139,6 +139,10 @@ export const apiEndpointKeys = { delete: "clouds.services.userpools.groups.delete", }, signInTest: "clouds.services.userpools.sign-in-test", + client: { + get: "clouds.services.userpools.clients.get", + updateOAuth: "clouds.services.userpools.clients.update-oauth", + }, }, k8s: { nodegroups: { @@ -882,6 +886,20 @@ export const endpointRegistry: EndpointRegistry = new Map([ 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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 0f5da65..393526f 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -31,6 +31,8 @@ import type { UserPoolGroup, UserPoolSignInTestInput, UserPoolSignInTestResult, + UserPoolClientSettings, + UpdateUserPoolClientOAuthInput, UserPoolUserAction, LogsInsightsQueryInput, LogsInsightsQueryResult, @@ -1474,6 +1476,34 @@ export async function testUserPoolSignIn( return res.data; } +export async function describeUserPoolClient( + cloud: CloudProvider, + poolId: string, + clientId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + apiEndpointKeys.clouds.userpools.client.updateOAuth, + requestOptions(cloud, "userpools", { body: input }), + { cloud, id: poolId, clientId }, + ); + return res.data; +} + export async function putFlatItem( cloud: CloudProvider, service: CloudServiceType, diff --git a/packages/frontend/src/components/CognitoAppClientSettings.tsx b/packages/frontend/src/components/CognitoAppClientSettings.tsx new file mode 100644 index 0000000..b643868 --- /dev/null +++ b/packages/frontend/src/components/CognitoAppClientSettings.tsx @@ -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(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) => { setNotice(''); setDraft((current) => current && {...current, ...patch}) } + const toggle = (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
+
+

App client

{client?.clientName || clientId}
+ +
+ {clientQuery.isLoading &&

Loading app client

} + {client && draft &&
{ event.preventDefault(); saveMutation.mutate(draft) }}> +
+
Client ID
{client.clientId}
+
Client secret
{client.hasSecret ? 'Yes' : 'No (public client)'}
+
Sign-in flows
{client.explicitAuthFlows.length ? client.explicitAuthFlows.map((flow) => {flow.replace(/^ALLOW_/, '')}) : '—'}
+
+ +
+

Hosted sign-in

+ + {otherProviders.length > 0 &&

Also signs in with: {otherProviders.map((provider) => {provider})}

} +

OAuth flows

+ {FLOWS.map(({flow, label, hint}) => ( + + ))} +
+ +
+

Scopes

+ {scopes.map((scope) => ( + + ))} +
+ +
+