diff --git a/api/application.yaml b/api/application.yaml index 2868264dc5..82edde7f9e 100644 --- a/api/application.yaml +++ b/api/application.yaml @@ -1435,15 +1435,22 @@ components: type: object description: > Platform attestation configuration used to verify the binary identity of a mobile client - when it initiates a flow directly over HTTP. Configure exactly one platform. - oneOf: - - required: [android] - - required: [apple] + when it initiates a flow directly over HTTP. Configure at most one platform; devMode is + independent of the platform fields and may be set with neither configured. + not: + required: [android, apple] properties: android: $ref: '#/components/schemas/AndroidAttestation' apple: $ref: '#/components/schemas/AppleAttestation' + devMode: + type: boolean + default: false + description: > + When true, skips the platform-attestation check for a mobile application during direct + flow initiation. Disabled by default; enable only for testing or trying out + sample/development mobile clients. AndroidAttestation: type: object diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index bfa298a12e..5949878b2c 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -1757,7 +1757,11 @@ func (as *applicationService) resolveAttestationCredentialsForPersist( } } - inboundClient.Attestation = &providers.AttestationConfig{Android: &android, Apple: inboundClient.Attestation.Apple} + inboundClient.Attestation = &providers.AttestationConfig{ + Android: &android, + Apple: inboundClient.Attestation.Apple, + DevMode: inboundClient.Attestation.DevMode, + } return nil } diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index 45043ee2da..5688715012 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -1833,6 +1833,30 @@ func (suite *ServiceTestSuite) TestResolveAttestationCredentials_PreservesExisti assert.Equal(suite.T(), "com.example.app", inboundClient.Attestation.Android.PackageName) } +// The Android credential rebuild must not drop a DevMode setting configured alongside it. +func (suite *ServiceTestSuite) TestResolveAttestationCredentials_PreservesDevMode() { + service, mockStore := suite.setupTestService() + + const appID = "app-1" + mockStore.On("GetInboundClientByEntityID", mock.Anything, appID).Return( + &inboundmodel.InboundClient{ + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{ServiceAccountCredentials: "stored-encrypted"}, + }, + }, nil) + + inboundClient := &inboundmodel.InboundClient{ + Attestation: &providers.AttestationConfig{ + Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"}, + DevMode: true, + }, + } + + svcErr := service.resolveAttestationCredentialsForPersist(context.Background(), appID, inboundClient) + require.Nil(suite.T(), svcErr) + assert.True(suite.T(), inboundClient.Attestation.DevMode) +} + // A non-"not found" lookup failure while preserving omitted credentials is propagated as an internal // error, so a transient store failure cannot silently overwrite stored credentials with an empty // value. diff --git a/backend/internal/flow/flowexec/constants.go b/backend/internal/flow/flowexec/constants.go index 82cbadebd4..2298bc37f0 100644 --- a/backend/internal/flow/flowexec/constants.go +++ b/backend/internal/flow/flowexec/constants.go @@ -38,4 +38,8 @@ const ( // identity. This takes precedence over the redirect-based classification for apps that configure // attestation. flowInitiationAttestation + // flowInitiationDevMode indicates a mobile application with attestation dev mode enabled, which may + // initiate a flow directly without presenting a platform attestation. Intended for testing and + // trying out sample or development mobile clients; disabled by default. + flowInitiationDevMode ) diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index d7c54a43df..3937fbbbc2 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -219,6 +219,9 @@ func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr // profile) that must authenticate at flow initiation by presenting its Flow Secret. // - Attestation — a mobile application that authenticates at flow initiation by presenting a valid // platform attestation (e.g. a Google Play Integrity token) proving its binary identity. +// - DevMode — a mobile application with attestation dev mode enabled, which may initiate a flow +// without presenting an attestation. Disabled by default; intended for testing and trying out +// sample/development mobile clients. // // Sign-out is guarded like authentication so a native caller must prove its identity before ending a // session; a redirect-based app is pushed to the RP-initiated /oauth2/logout endpoint instead. Other @@ -266,6 +269,8 @@ func (s *flowExecService) checkDirectFlowInitiationAllowed(ctx context.Context, return nil case flowInitiationAttestation: return s.verifyAttestation(ctx, attestationCfg, attestationToken) + case flowInitiationDevMode: + return nil default: logger.Error(ctx, "Unknown flow initiation mode for application", log.String("appID", appID)) @@ -309,7 +314,12 @@ func (s *flowExecService) resolveFlowInitiationMode( // M2M apps get tokens directly; browser apps are public redirect clients. Neither runs flows. return flowInitiationNotPermitted, nil, nil case appmodel.ApplicationTypeMobile: - // Mobile apps authenticate with platform attestation, which must be configured first. + // Dev mode lets a mobile app initiate flows without a platform attestation, for testing or + // trying out sample/development clients. Disabled by default. + if client.Attestation != nil && client.Attestation.DevMode { + return flowInitiationDevMode, nil, nil + } + // Otherwise, mobile apps authenticate with platform attestation, which must be configured first. if client.Attestation == nil || (client.Attestation.Android == nil && client.Attestation.Apple == nil) { return 0, nil, &ErrorAttestationNotConfigured } diff --git a/backend/internal/flow/flowexec/service_test.go b/backend/internal/flow/flowexec/service_test.go index 0fc4287cc5..f63baa79cd 100644 --- a/backend/internal/flow/flowexec/service_test.go +++ b/backend/internal/flow/flowexec/service_test.go @@ -2431,6 +2431,11 @@ func (s *ServiceTestSuite) TestResolveFlowInitiationMode_ByType() { attestation: &providers.AttestationConfig{Apple: &providers.AppleAttestationConfig{}}, expectMode: flowInitiationAttestation, }, + { + name: "mobile with dev mode skips attestation", appType: model.ApplicationTypeMobile, + attestation: &providers.AttestationConfig{DevMode: true}, + expectMode: flowInitiationDevMode, + }, { name: "mcp embedded uses flow secret", appType: model.ApplicationTypeMCP, profile: &providers.OAuthProfile{GrantTypes: []string{"client_credentials", tokenExchange}}, @@ -3058,6 +3063,30 @@ func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_AppleAttestation s.Nil(svcErr) } +// A mobile app with attestation dev mode enabled may initiate a flow directly without presenting an +// attestation token, and no verification is attempted. +func (s *ServiceTestSuite) TestCheckDirectFlowInitiationAllowed_DevModeSkipsAttestation() { + t := s.T() + mockActorProvider := actorprovidermock.NewActorProviderMock(t) + devModeClient := &providers.InboundClient{ + ID: "mobile-app", + Properties: map[string]interface{}{ + applicationTypePropertyKey: string(model.ApplicationTypeMobile), + }, + Attestation: &providers.AttestationConfig{DevMode: true}, + } + mockActorProvider.EXPECT().GetInboundClientByID(mock.Anything, "mobile-app").Return(devModeClient, nil) + + service := &flowExecService{ + actorProvider: mockActorProvider, + cfg: testFlowExecCfg, + } + + svcErr := service.checkDirectFlowInitiationAllowed(context.Background(), "mobile-app", + providers.FlowTypeAuthentication, "", "", log.GetLogger()) + s.Nil(svcErr) +} + // --- getFlowContext --- func (s *ServiceTestSuite) TestGetFlowContext_NilDbModel() { diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 68ecaa345b..5c9c99c65c 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -635,6 +635,7 @@ type Certificate struct { type AttestationConfig struct { Android *AndroidAttestationConfig `json:"android,omitempty" yaml:"android,omitempty" jsonschema:"Google Play Integrity attestation configuration for Android clients."` Apple *AppleAttestationConfig `json:"apple,omitempty" yaml:"apple,omitempty" jsonschema:"Apple App Attest attestation configuration for iOS clients."` + DevMode bool `json:"devMode,omitempty" yaml:"devMode,omitempty" jsonschema:"When true, skips the platform-attestation check for a mobile application during direct flow initiation. Disabled by default; enable only for testing or trying out sample/development mobile clients."` } // AndroidAttestationConfig holds the Google Play Integrity settings for an Android application. @@ -656,7 +657,7 @@ func (c *AttestationConfig) WithoutCredentials() *AttestationConfig { if c == nil { return nil } - sanitized := &AttestationConfig{} + sanitized := &AttestationConfig{DevMode: c.DevMode} if c.Android != nil { android := *c.Android android.ServiceAccountCredentials = "" diff --git a/backend/pkg/thunderidengine/providers/model_test.go b/backend/pkg/thunderidengine/providers/model_test.go index 04fe331357..3dc48b028c 100644 --- a/backend/pkg/thunderidengine/providers/model_test.go +++ b/backend/pkg/thunderidengine/providers/model_test.go @@ -294,3 +294,11 @@ func (suite *ModelTestSuite) TestAttestationConfig_WithoutCredentials_PassesAppl assert.Equal(suite.T(), "com.example.app", sanitized.Apple.BundleID) assert.Nil(suite.T(), sanitized.Android) } + +func (suite *ModelTestSuite) TestAttestationConfig_WithoutCredentials_PassesDevModeThrough() { + cfg := &AttestationConfig{DevMode: true} + + sanitized := cfg.WithoutCredentials() + + assert.True(suite.T(), sanitized.DevMode) +} diff --git a/docs/content/guides/applications/application-settings.mdx b/docs/content/guides/applications/application-settings.mdx index de93302169..ad8f858b45 100644 --- a/docs/content/guides/applications/application-settings.mdx +++ b/docs/content/guides/applications/application-settings.mdx @@ -189,6 +189,14 @@ attestation: ``` ::: +### Dev Mode + +Configuring `attestation.android` or `attestation.apple` requires a signed build with the exact package name, signing certificate, or bundle identifier registered above. A sample application or a local development build usually cannot produce a valid attestation. **Dev Mode** lets a Mobile application initiate a flow directly without presenting an attestation token, regardless of whether an Android or iOS platform is configured. Enable it from the toggle in the **Platform Attestation** card header, or set `attestation.devMode` to `true` in a declarative resource. **Dev Mode** is disabled by default. + +:::warning +**Dev Mode** skips the platform-attestation check only when this application initiates a flow directly. It does not affect any other application operation. Use it only for testing, or to try out a sample or development client. Disable it before the application goes to production. +::: + ## Related Guides - [Manage Applications](../manage-applications) - Create, update, and delete applications diff --git a/docs/content/key-concepts/authentication/integration-models.mdx b/docs/content/key-concepts/authentication/integration-models.mdx index 045058c26d..23544d1d02 100644 --- a/docs/content/key-concepts/authentication/integration-models.mdx +++ b/docs/content/key-concepts/authentication/integration-models.mdx @@ -105,6 +105,10 @@ Attestation-Token: A Mobile application that has not configured attestation is rejected with `400 Bad Request`. Once attestation is configured, an application that omits the token is rejected with `401 Unauthorized`, and an invalid or malformed token is also rejected with `401 Unauthorized`. Flow **continuation** requests (those carrying an `executionId`) do not require an attestation token. +:::note +A Mobile application with **Dev Mode** enabled is an exception to the rejections above. It can initiate a flow without presenting an attestation token, regardless of whether a platform is configured. **Dev Mode** is intended for testing and trying out sample or development clients, not production use. See [Dev Mode](../../guides/applications/application-settings.mdx#dev-mode). +::: + ### Integration Modes App-native authentication supports two modes, **Verbose** and **Non-Verbose**, controlled via the `verbose` field in the Flow Execution API. These modes offer different levels of UI granularity to the application. A SDK typically consumes these modes and manages API calls, state, and response parsing. Both modes follow the same request-response cycle: the application calls the Flow Execution API, receives the next step, renders the screen, collects user input, and submits the response back. The difference lies in how much detail returns for each step. diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx index d10d0c7d71..ed0f20f634 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/AttestationSection.tsx @@ -4,13 +4,16 @@ import {SettingsCard} from '@thunderid/components'; import type {AttestationConfig} from '@thunderid/configure-applications'; import { + Alert, Autocomplete, Box, Button, FormControl, + FormControlLabel, FormLabel, IconButton, Stack, + Switch, TextField, Tooltip, Typography, @@ -18,6 +21,7 @@ import { import {Plus, Trash} from '@wso2/oxygen-ui-icons-react'; import {useEffect, useRef, useState} from 'react'; import {useTranslation} from 'react-i18next'; +import DevModeConfirmDialog from './DevModeConfirmDialog'; /** * The attestation platform an application is configured for. An application configures exactly one @@ -74,6 +78,14 @@ function platformOf(attestation?: AttestationConfig | null): AttestationPlatform * Apple's Team ID and Bundle ID are required together: a config with only one of the two is never * emitted to the parent, since the backend cannot verify an incomplete identity. * + * Dev Mode is independent of the platform fields: when enabled, the application may initiate flows + * without presenting an attestation at all, regardless of whether a platform is configured. Its toggle + * lives in the card header, to the right of the title, matching the enable toggle placement used + * elsewhere in the edit page (e.g. flow sections). It is disabled by default and intended only for + * testing or trying out sample/development mobile clients; a warning banner is shown while it is on. + * Turning it on requires confirmation via {@link DevModeConfirmDialog}, since it skips attestation + * verification; turning it off applies immediately. + * * @param props - Component props * @returns Attestation configuration UI within a SettingsCard */ @@ -97,13 +109,21 @@ export default function AttestationSection({ const [credentials, setCredentials] = useState(''); const [teamId, setTeamId] = useState(apple?.teamId ?? ''); const [bundleId, setBundleId] = useState(apple?.bundleId ?? ''); + const [devMode, setDevMode] = useState(attestation?.devMode ?? false); + const [isDevModeConfirmOpen, setIsDevModeConfirmOpen] = useState(false); // Canonical identity of the incoming config (platform + non-secret fields). The effect below // resyncs local state when the attestation prop is replaced externally — e.g. the application // reloads, or the config is cleared — while ignoring the echo of this component's own emissions // (tracked via the ref). Credentials are write-only and never part of the identity. - const computeIdentity = (p: AttestationPlatform, pkg: string, digs: string[], team: string, bundle: string) => - JSON.stringify({platform: p, packageName: pkg, digests: digs, teamId: team, bundleId: bundle}); + const computeIdentity = ( + p: AttestationPlatform, + pkg: string, + digs: string[], + team: string, + bundle: string, + dev: boolean, + ) => JSON.stringify({platform: p, packageName: pkg, digests: digs, teamId: team, bundleId: bundle, devMode: dev}); const identityKey = computeIdentity( propPlatform, @@ -111,6 +131,7 @@ export default function AttestationSection({ android?.certificateSha256Digests ?? [], apple?.teamId ?? '', apple?.bundleId ?? '', + attestation?.devMode ?? false, ); const lastSyncedKeyRef = useRef(identityKey); @@ -124,6 +145,7 @@ export default function AttestationSection({ setDigests(android?.certificateSha256Digests ?? []); setTeamId(apple?.teamId ?? ''); setBundleId(apple?.bundleId ?? ''); + setDevMode(attestation?.devMode ?? false); // Credentials are write-only; an external config change resets the editable field to blank. setCredentials(''); // identityKey is the canonical trigger; the config values are read for what it encodes. @@ -147,6 +169,7 @@ export default function AttestationSection({ creds: string, team: string, bundle: string, + dev: boolean, ) => { const cleanedDigests = digs.map((d) => d.trim()).filter((d) => d !== ''); const cleanedPackageName = pkg.trim(); @@ -176,23 +199,41 @@ export default function AttestationSection({ config = {apple: {teamId: cleanedTeamId, bundleId: cleanedBundleId}}; identityPlatform = 'apple'; } else if (cleanedTeamId !== '' || cleanedBundleId !== '') { - // Exactly one of the two is set: an incomplete apple config. Do not emit it — that would - // persist an identity the backend can never verify. Skip the update entirely so the - // parent keeps its last valid value (complete or cleared) while the user finishes - // entering the other field; appleIncomplete (above) surfaces a validation hint instead. - return; + // Exactly one of the two is set: an incomplete apple config. Never emit it as the platform + // config — that would persist an identity the backend can never verify; appleIncomplete + // (above) surfaces a validation hint instead. If this call isn't actually changing dev + // mode, skip the update entirely so the parent keeps its last valid value while the user + // finishes entering the other field. If it is a dev-mode transition, fall through with the + // last valid platform preserved (from the attestation prop) so the transition still reaches + // the parent instead of being silently dropped. + if (dev === devMode) { + return; + } + config = apple ? {apple} : android ? {android} : null; + identityPlatform = propPlatform; } // Both empty falls through with config left at null, clearing any stored apple config. } + // Dev mode is independent of the platform fields, so it may keep a config alive (or create one) + // even when no platform is configured. + if (dev) { + config = {...config, devMode: true}; + } + // Record the identity being emitted so the resync effect ignores the resulting prop echo and - // preserves the user's in-progress edits. + // preserves the user's in-progress edits. Derived from the emitted config itself, rather than + // the raw typed fields, so a preserved (not freshly typed) platform config above stays + // consistent with what was actually emitted. + const emittedAndroid = identityPlatform === 'android' ? config?.android : undefined; + const emittedApple = identityPlatform === 'apple' ? config?.apple : undefined; lastSyncedKeyRef.current = computeIdentity( identityPlatform, - identityPlatform === 'android' ? cleanedPackageName : '', - identityPlatform === 'android' ? cleanedDigests : [], - identityPlatform === 'apple' ? cleanedTeamId : '', - identityPlatform === 'apple' ? cleanedBundleId : '', + emittedAndroid?.packageName ?? '', + emittedAndroid?.certificateSha256Digests ?? [], + emittedApple?.teamId ?? '', + emittedApple?.bundleId ?? '', + dev, ); onAttestationChange(config); }; @@ -208,32 +249,56 @@ export default function AttestationSection({ const handlePlatformChange = (next: AttestationPlatform) => { setPlatform(next); - emit(next, packageName, digests, credentials, teamId, bundleId); + emit(next, packageName, digests, credentials, teamId, bundleId, devMode); }; const handlePackageNameChange = (value: string) => { setPackageName(value); - emit(platform, value, digests, credentials, teamId, bundleId); + emit(platform, value, digests, credentials, teamId, bundleId, devMode); }; const handleCredentialsChange = (value: string) => { setCredentials(value); - emit(platform, packageName, digests, value, teamId, bundleId); + emit(platform, packageName, digests, value, teamId, bundleId, devMode); }; const handleTeamIdChange = (value: string) => { setTeamId(value); - emit(platform, packageName, digests, credentials, value, bundleId); + emit(platform, packageName, digests, credentials, value, bundleId, devMode); }; const handleBundleIdChange = (value: string) => { setBundleId(value); - emit(platform, packageName, digests, credentials, teamId, value); + emit(platform, packageName, digests, credentials, teamId, value, devMode); }; const commitDigests = (nextDigests: string[]) => { setDigests(nextDigests); - emit(platform, packageName, nextDigests, credentials, teamId, bundleId); + emit(platform, packageName, nextDigests, credentials, teamId, bundleId, devMode); + }; + + const applyDevModeChange = (value: boolean) => { + setDevMode(value); + emit(platform, packageName, digests, credentials, teamId, bundleId, value); + }; + + // Turning dev mode on skips attestation verification entirely, so it requires confirmation. + // Turning it off is always safe and applies immediately. + const handleDevModeToggle = (value: boolean) => { + if (value) { + setIsDevModeConfirmOpen(true); + return; + } + applyDevModeChange(false); + }; + + const handleDevModeConfirm = () => { + setIsDevModeConfirmOpen(false); + applyDevModeChange(true); + }; + + const handleDevModeCancel = () => { + setIsDevModeConfirmOpen(false); }; const handleAddDigest = () => { @@ -248,6 +313,23 @@ export default function AttestationSection({ commitDigests(digests.filter((_, i) => i !== index)); }; + const devModeLabel = t('applications:edit.advanced.attestation.labels.devMode', 'Dev Mode'); + const devModeSwitch = ( + handleDevModeToggle(e.target.checked)} + inputProps={{'aria-label': devModeLabel}} + /> + } + label={{devModeLabel}} + sx={{ml: 0}} + /> + ); + return ( + {devModeSwitch} + + } > + {devMode && ( + + {t( + 'applications:edit.advanced.attestation.warning.devMode', + 'Dev mode is enabled. Attestation verification is skipped for this application. Use this only ' + + 'for testing, do not enable it in production.', + )} + + )} + {t('applications:edit.advanced.attestation.labels.platform', 'Platform')} @@ -442,6 +545,12 @@ export default function AttestationSection({ )} + + ); } diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/DevModeConfirmDialog.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/DevModeConfirmDialog.tsx new file mode 100644 index 0000000000..be8a8ef690 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/DevModeConfirmDialog.tsx @@ -0,0 +1,54 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from '@wso2/oxygen-ui'; +import type {JSX} from 'react'; +import {useTranslation} from 'react-i18next'; + +export interface DevModeConfirmDialogProps { + /** + * Whether the dialog is open. + */ + open: boolean; + /** + * Callback when the dialog should be closed without enabling dev mode. + */ + onClose: () => void; + /** + * Callback when the user confirms enabling dev mode. + */ + onConfirm: () => void; +} + +/** + * Confirmation dialog shown before enabling attestation dev mode on a mobile application. + */ +export default function DevModeConfirmDialog({open, onClose, onConfirm}: DevModeConfirmDialogProps): JSX.Element { + const {t} = useTranslation(); + + return ( + + + {t('applications:edit.advanced.attestation.devModeConfirmDialog.title', 'Enable Dev Mode?')} + + + + {t( + 'applications:edit.advanced.attestation.devModeConfirmDialog.description', + 'This skips attestation verification for this application, so it can initiate a sign-in flow ' + + 'without presenting an attestation token. Use it only for testing, or to try out a sample or ' + + 'development client. Do not enable it in production.', + )} + + + + + + + + ); +} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx index 39ed33d7f9..d88a842394 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/AttestationSection.test.tsx @@ -83,6 +83,20 @@ describe('AttestationSection', () => { expect(screen.queryByDisplayValue('secret-json')).not.toBeInTheDocument(); }); + it('should render the dev mode toggle unchecked by default', () => { + render(); + + const toggle = screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode'); + expect(toggle).not.toBeChecked(); + }); + + it('should render the dev mode toggle checked when dev mode is enabled', () => { + render(); + + const toggle = screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode'); + expect(toggle).toBeChecked(); + }); + it('should render the Apple fields with values when an apple config is present', () => { render( { }); }); + describe('Dev Mode', () => { + it('should open the confirm dialog without emitting when toggled on', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(mockOnAttestationChange).not.toHaveBeenCalled(); + }); + + it('should emit a dev mode config when the confirm dialog is accepted', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + await user.click(screen.getByTestId('dev-mode-confirm-button')); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith({devMode: true}); + }); + + it('should not emit or check the toggle when the confirm dialog is canceled', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + await user.click(screen.getByText('applications:edit.advanced.attestation.devModeConfirmDialog.cancelButton')); + + expect(mockOnAttestationChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')).not.toBeChecked(); + }); + + it('should emit null immediately when dev mode is disabled again, without a confirm dialog', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith(null); + }); + + it('should keep dev mode set alongside an android config once confirmed', async () => { + const user = userEvent.setup({delay: null}); + render( + , + ); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + await user.click(screen.getByTestId('dev-mode-confirm-button')); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith({ + android: {packageName: 'com.example.app'}, + devMode: true, + }); + }); + + it('should still emit enabling dev mode when the apple config is left incomplete', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await selectPlatform(user, 'applications:edit.advanced.attestation.platform.apple'); + await user.type(screen.getByLabelText('applications:edit.advanced.attestation.labels.teamId'), 'A'); + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + await user.click(screen.getByTestId('dev-mode-confirm-button')); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith({devMode: true}); + }); + + it('should still emit the preserved apple config when disabling dev mode while apple is left incomplete', async () => { + const user = userEvent.setup({delay: null}); + render( + , + ); + + await user.clear(screen.getByLabelText('applications:edit.advanced.attestation.labels.bundleId')); + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + + expect(mockOnAttestationChange).toHaveBeenLastCalledWith({ + apple: {teamId: 'ABCDE12345', bundleId: 'com.example.myapp'}, + }); + }); + + it('should not show the warning banner when dev mode is disabled', () => { + render(); + + expect(screen.queryByText('applications:edit.advanced.attestation.warning.devMode')).not.toBeInTheDocument(); + }); + + it('should show the warning banner when dev mode is enabled', () => { + render(); + + expect(screen.getByText('applications:edit.advanced.attestation.warning.devMode')).toBeInTheDocument(); + }); + + it('should not show the warning banner until the confirm dialog is accepted', async () => { + const user = userEvent.setup({delay: null}); + render(); + + await user.click(screen.getByLabelText('applications:edit.advanced.attestation.labels.devMode')); + expect(screen.queryByText('applications:edit.advanced.attestation.warning.devMode')).not.toBeInTheDocument(); + + await user.click(screen.getByTestId('dev-mode-confirm-button')); + + expect(screen.getByText('applications:edit.advanced.attestation.warning.devMode')).toBeInTheDocument(); + }); + }); + describe('Validation', () => { it('reports a validation error while the apple config is incomplete, resolving once both fields are set', async () => { const user = userEvent.setup({delay: null}); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/DevModeConfirmDialog.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/DevModeConfirmDialog.test.tsx new file mode 100644 index 0000000000..d705419c90 --- /dev/null +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/DevModeConfirmDialog.test.tsx @@ -0,0 +1,52 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {fireEvent, render, screen} from '@testing-library/react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import DevModeConfirmDialog from '../DevModeConfirmDialog'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => defaultValue ?? key, + }), +})); + +describe('DevModeConfirmDialog', () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render the dialog when open', () => { + render(); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Enable Dev Mode?')).toBeInTheDocument(); + }); + + it('should not render dialog content when closed', () => { + render(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should call onConfirm when the confirm button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('dev-mode-confirm-button')); + + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + expect(mockOnClose).not.toHaveBeenCalled(); + }); + + it('should call onClose when the cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByText('Cancel')); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/packages/configure-applications/src/models/oauth.ts b/frontend/packages/configure-applications/src/models/oauth.ts index 7cffc2e3d5..fb5fba52c0 100644 --- a/frontend/packages/configure-applications/src/models/oauth.ts +++ b/frontend/packages/configure-applications/src/models/oauth.ts @@ -489,11 +489,22 @@ export interface OAuth2Config { } /** - * Platform attestation configuration for an application. An application configures exactly one - * platform: the `android` and `apple` variants are mutually exclusive, so `{}` and a config with - * both set are both compile-time errors. + * Platform attestation configuration for an application. `android` and `apple` are mutually + * exclusive: configuring both is a compile-time error, matching the backend's rejection of a + * config with both set. `devMode` is independent of the platform fields, so it may be set on its + * own with neither platform configured. */ -export type AttestationConfig = +export type AttestationConfig = { + /** + * When true, skips attestation verification for this application. Disabled by default; enable + * only for testing or trying out sample/development mobile clients. + */ + devMode?: boolean; +} & ( + | { + android?: undefined; + apple?: undefined; + } | { /** * Google Play Integrity attestation configuration for Android clients. @@ -507,7 +518,8 @@ export type AttestationConfig = * Apple App Attest attestation configuration for iOS clients. */ apple: AppleAttestationConfig; - }; + } +); /** * Google Play Integrity attestation settings for an Android application. diff --git a/frontend/packages/i18n/src/locales/en-US.ts b/frontend/packages/i18n/src/locales/en-US.ts index 6caf2ac4fe..ee6eb33f35 100644 --- a/frontend/packages/i18n/src/locales/en-US.ts +++ b/frontend/packages/i18n/src/locales/en-US.ts @@ -2536,6 +2536,20 @@ const translations = { 'edit.advanced.attestation.hint.teamId': 'The Apple Developer Team ID.', 'edit.advanced.attestation.hint.bundleId': 'The iOS bundle identifier that must match the attested app.', 'edit.advanced.attestation.error.appleIncomplete': 'Both Team ID and Bundle ID are required together.', + 'edit.advanced.attestation.labels.devMode': 'Dev Mode', + 'edit.advanced.attestation.hint.devMode': + 'Skips attestation verification for this application. Enable only for testing or trying out ' + + 'sample/development mobile clients; leave disabled otherwise.', + 'edit.advanced.attestation.warning.devMode': + 'Dev mode is enabled. Attestation verification is skipped for this application. Use this only for ' + + 'testing, do not enable it in production.', + 'edit.advanced.attestation.devModeConfirmDialog.title': 'Enable Dev Mode?', + 'edit.advanced.attestation.devModeConfirmDialog.description': + 'This skips attestation verification for this application, so it can initiate a sign-in flow ' + + 'without presenting an attestation token. Use it only for testing, or to try out a sample or ' + + 'development client. Do not enable it in production.', + 'edit.advanced.attestation.devModeConfirmDialog.cancelButton': 'Cancel', + 'edit.advanced.attestation.devModeConfirmDialog.confirmButton': 'Enable Dev Mode', /* Passkeys section */ 'edit.advanced.labels.passkeys': 'Passkey Allowed Origins',