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) => ( + + ))} +
+ +
+