Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51a0a60a73 |
@@ -16,7 +16,9 @@ import {AwsLightsailAdapter} from './AwsLightsailAdapter'
|
|||||||
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
const originalEndpoint = process.env.FLOCI_ENDPOINT
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
// Assigning undefined would store the string "undefined" for every later test file.
|
||||||
|
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||||
|
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
function stubLightsail() {
|
function stubLightsail() {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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'
|
||||||
@@ -126,31 +125,9 @@ 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}}
|
||||||
@@ -410,63 +387,3 @@ 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,13 +24,11 @@ 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,
|
||||||
@@ -40,7 +38,6 @@ 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,
|
||||||
@@ -52,10 +49,7 @@ import type {
|
|||||||
CreateUserPoolGroupInput,
|
CreateUserPoolGroupInput,
|
||||||
ResourceQuery,
|
ResourceQuery,
|
||||||
ServiceSchema,
|
ServiceSchema,
|
||||||
UpdateUserPoolClientOAuthInput,
|
|
||||||
UserPoolClientSettings,
|
|
||||||
UserPoolGroup,
|
UserPoolGroup,
|
||||||
UserPoolOAuthFlow,
|
|
||||||
UserPoolSignInTestInput,
|
UserPoolSignInTestInput,
|
||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
@@ -291,54 +285,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -514,103 +460,10 @@ 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,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const realFetch = globalThis.fetch
|
|||||||
let passwordHash: string
|
let passwordHash: string
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
passwordHash = await Bun.password.hash('correct-horse-battery')
|
// Cheap parameters keep the many sign-in attempts below fast on slow CI runners; verification
|
||||||
|
// reads the parameters from the hash, so the code under test is unchanged.
|
||||||
|
passwordHash = await Bun.password.hash('correct-horse-battery', {algorithm: 'argon2id', memoryCost: 1024, timeCost: 1})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -239,7 +241,8 @@ describe('auth state cleanup', () => {
|
|||||||
test('caps pending OIDC requests and sweeps expired state', async () => {
|
test('caps pending OIDC requests and sweeps expired state', async () => {
|
||||||
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
const config = authConfig(oidcEnv({FLOCI_UI_OIDC_ALLOW_ALL_USERS: 'true'}))
|
||||||
const hono = app(config)
|
const hono = app(config)
|
||||||
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks'})) as unknown as typeof fetch
|
// The same document as mockIdp: discovery is cached per issuer for the rest of the run.
|
||||||
|
globalThis.fetch = (async () => Response.json({authorization_endpoint: 'https://idp.test/authorize', token_endpoint: 'https://idp.test/token', jwks_uri: 'https://idp.test/jwks', userinfo_endpoint: 'https://idp.test/userinfo'})) as unknown as typeof fetch
|
||||||
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
for (let index = 0; index < 1_050; index++) await request(hono, '/api/auth/oidc/login', `10.1.${Math.floor(index / 250)}.${index % 250}`)
|
||||||
|
|
||||||
expect(authStateSizes().oidcRequests).toBe(1_000)
|
expect(authStateSizes().oidcRequests).toBe(1_000)
|
||||||
|
|||||||
@@ -178,8 +178,6 @@ 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> {
|
||||||
@@ -455,29 +453,6 @@ 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}
|
||||||
@@ -995,8 +970,6 @@ 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,8 +81,6 @@ 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,
|
||||||
@@ -94,10 +92,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ 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()
|
||||||
|
|||||||
@@ -747,28 +747,6 @@ 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', {
|
||||||
|
|||||||
@@ -447,20 +447,6 @@ 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)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
import {beforeEach, describe, expect, mock, test} from 'bun:test'
|
||||||
|
|
||||||
// The secretsmanager router imports the `secretsManager` singleton directly from
|
// The secretsmanager router imports the `secretsManager` singleton directly from
|
||||||
// `../aws`, so we replace that module with a stub whose `send()` is driven per
|
// `../aws`, so we replace that export with a stub whose `send()` is driven per
|
||||||
// test. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
// test. Bun keeps a module mock for the rest of the run, so every other export
|
||||||
|
// stays real: test files that load `../aws` after this one still get it whole. `responder` returns the fake AWS SDK response and `lastCommand` captures
|
||||||
// the command that was dispatched so we can assert on its input shape.
|
// the command that was dispatched so we can assert on its input shape.
|
||||||
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
let lastCommand: {constructor: {name: string}; input: Record<string, unknown>} | null = null
|
||||||
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
let responder: (commandName: string, command: {input: Record<string, unknown>}) => unknown
|
||||||
|
|
||||||
|
const realAws = await import('../aws')
|
||||||
mock.module('../aws', () => ({
|
mock.module('../aws', () => ({
|
||||||
|
...realAws,
|
||||||
secretsManager: {
|
secretsManager: {
|
||||||
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
async send(command: {constructor: {name: string}; input: Record<string, unknown>}) {
|
||||||
lastCommand = command
|
lastCommand = command
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ import type {
|
|||||||
UserPoolGroup,
|
UserPoolGroup,
|
||||||
UserPoolSignInTestInput,
|
UserPoolSignInTestInput,
|
||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
|
||||||
UpdateUserPoolClientOAuthInput,
|
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
} from '../cloud-spi/types'
|
} from '../cloud-spi/types'
|
||||||
import type {
|
import type {
|
||||||
@@ -833,18 +831,6 @@ 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`)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ const originalEndpoint = process.env.FLOCI_ENDPOINT
|
|||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env.FLOCI_ENDPOINT = originalEndpoint
|
// Assigning undefined would store the string "undefined" for every later test file.
|
||||||
|
if (originalEndpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||||
|
else process.env.FLOCI_ENDPOINT = originalEndpoint
|
||||||
globalThis.fetch = originalFetch
|
globalThis.fetch = originalFetch
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ 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: {
|
||||||
@@ -886,20 +882,6 @@ 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,8 +31,6 @@ import type {
|
|||||||
UserPoolGroup,
|
UserPoolGroup,
|
||||||
UserPoolSignInTestInput,
|
UserPoolSignInTestInput,
|
||||||
UserPoolSignInTestResult,
|
UserPoolSignInTestResult,
|
||||||
UserPoolClientSettings,
|
|
||||||
UpdateUserPoolClientOAuthInput,
|
|
||||||
UserPoolUserAction,
|
UserPoolUserAction,
|
||||||
LogsInsightsQueryInput,
|
LogsInsightsQueryInput,
|
||||||
LogsInsightsQueryResult,
|
LogsInsightsQueryResult,
|
||||||
@@ -1476,34 +1474,6 @@ 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,
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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,8 +17,6 @@ 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)
|
||||||
@@ -66,50 +64,6 @@ 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,10 +2,8 @@ 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'
|
||||||
@@ -26,7 +24,6 @@ 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]
|
||||||
|
|
||||||
@@ -96,16 +93,9 @@ 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><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></tr></thead><tbody>{appClients.map((client) => (
|
||||||
<tr key={client.clientId}>
|
<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>
|
||||||
<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}/>
|
||||||
|
|
||||||
|
|||||||
@@ -4395,21 +4395,6 @@ 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;
|
||||||
|
|||||||
@@ -302,22 +302,6 @@ 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}
|
||||||
|
|||||||
@@ -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' | 'describeClient' | 'updateClientOAuth'
|
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn'
|
||||||
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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user