Skip to content

Commit 779d054

Browse files
authored
feat: add settings, verification, and code config attributes to project_config (#151)
* feat: add settings, verification, and code config attributes to project_config Add support for configuring selfservice settings flow, verification flow, profile method, and code method config on the ory_project_config resource. New attributes: - enable_profile: toggle the profile authentication method - code_lifespan: one-time code validity duration - code_max_submissions: max code submission attempts (read-only in API) - code_missing_credential_fallback_enabled: code as credential fallback - settings_lifespan: settings flow session duration - settings_privileged_session_max_age: re-auth window for privileged changes - verification_use: verification method (code or link) - verification_lifespan: verification flow session duration - verification_notify_unknown_recipients: notify unknown email addresses - required_aal: corrected validator to match API (aal1, highest_available) Closes #150 * fix: address review feedback, remove code_max_submissions - Remove code_max_submissions attribute entirely (API silently ignores writes, always returns server-default value of 5) - Add p.Op assertions to all new unit tests for consistency - Add null-case tests for CodeMissingCredentialFallbackEnabled, SettingsPrivilegedSessionMaxAge, VerificationLifespan, and VerificationNotifyUnknownRecipients
1 parent 001909b commit 779d054

File tree

7 files changed

+560
-6
lines changed

7 files changed

+560
-6
lines changed

docs/resources/project_config.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,26 @@ resource "ory_project_config" "secure" {
5757
enable_oidc = true # Required for social providers (Google, GitHub, etc.)
5858
enable_oidc_auto_link_policy = true # Allow social providers with auto_link = true to link to existing identities
5959
enable_passkey = true
60+
enable_profile = true # Allow users to update profile traits via settings flow
61+
62+
# Code Method Configuration
63+
code_lifespan = "15m0s" # How long a code remains valid
64+
code_missing_credential_fallback_enabled = true # Use code as fallback when primary credential is missing
6065
6166
# Flow Controls
6267
enable_registration = true
6368
enable_recovery = true
6469
enable_verification = true
6570
71+
# Settings Flow
72+
settings_lifespan = "30m0s" # How long a settings flow session is valid
73+
settings_privileged_session_max_age = "15m0s" # Re-auth required for privileged changes after this duration
74+
75+
# Verification Flow
76+
verification_use = "code" # Use one-time code for verification (or "link")
77+
verification_lifespan = "30m0s" # How long a verification flow session is valid
78+
verification_notify_unknown_recipients = false # Don't send verification emails to unknown addresses
79+
6680
# MFA
6781
enable_totp = true
6882
totp_issuer = "MyApp"
@@ -317,9 +331,11 @@ This resource exposes **75+ attributes** across these configuration categories:
317331
| Session settings | cookie same site, lifespan, whoami-required AAL |
318332
| CORS | public and admin origins, enabled/disabled |
319333
| Login flow | login style (unified, identifier_first) |
320-
| Authentication | passwordless, code, OIDC (social sign-in), OIDC auto-link policy, TOTP, passkey, WebAuthn, lookup secrets |
334+
| Authentication | passwordless, code, profile, OIDC (social sign-in), OIDC auto-link policy, TOTP, passkey, WebAuthn, lookup secrets |
335+
| Code method | lifespan, max submissions, missing credential fallback |
321336
| OAuth2/Hydra | token lifespans, access token strategy, PKCE, claims, scope strategy, consent/login URLs |
322-
| Recovery / Verification | enabled, methods, notify unknown recipients |
337+
| Settings flow | lifespan, privileged session max age, required AAL |
338+
| Recovery / Verification | enabled, methods, lifespan, notify unknown recipients |
323339
| Account enumeration | mitigation enabled |
324340
| Keto | namespace configuration |
325341

@@ -342,7 +358,9 @@ Some Ory project settings are not yet available through this resource. For setti
342358
- `account_experience_name` (String) Application name shown in the hosted login UI.
343359
- `account_experience_stylesheet` (String) Custom CSS stylesheet for the hosted login UI.
344360
- `allowed_return_urls` (List of String) List of allowed return URLs.
361+
- `code_lifespan` (String) Lifespan of the code method's one-time codes (e.g., '15m0s'). Controls how long a code remains valid after being issued.
345362
- `code_mfa_enabled` (Boolean) Enable the code method as a second factor for MFA. When enabled, users can use one-time codes as a second authentication factor.
363+
- `code_missing_credential_fallback_enabled` (Boolean) Enable missing credential fallback for the code method. When enabled, allows the code method to be used as a fallback when the primary credential is missing.
346364
- `cors_admin_enabled` (Boolean) Enable CORS for the admin API.
347365
- `cors_admin_origins` (List of String) Allowed CORS origins for the admin API.
348366
- `cors_enabled` (Boolean) Enable CORS for the public API.
@@ -357,6 +375,7 @@ Some Ory project settings are not yet available through this resource. For setti
357375
- `enable_oidc_auto_link_policy` (Boolean) Enable the OIDC auto-link policy. When true, social sign-in providers with auto_link enabled (on ory_social_provider) can automatically link to existing identities that share the same identifier (e.g., email).
358376
- `enable_passkey` (Boolean) Enable Passkey authentication.
359377
- `enable_password` (Boolean) Enable password authentication.
378+
- `enable_profile` (Boolean) Enable the profile authentication method. When enabled, users can update their identity traits (e.g., name, address) via the settings flow.
360379
- `enable_recovery` (Boolean) Enable password recovery flow.
361380
- `enable_registration` (Boolean) Enable user registration.
362381
- `enable_totp` (Boolean) Enable TOTP (Time-based One-Time Password).
@@ -394,19 +413,24 @@ Some Ory project settings are not yet available through this resource. For setti
394413
- `project_id` (String) Project ID to configure. If not set, uses provider's project_id.
395414
- `recovery_ui_url` (String) URL for the password recovery UI.
396415
- `registration_ui_url` (String) URL for the registration UI.
397-
- `required_aal` (String) Required Authenticator Assurance Level for protected resources: 'aal1' or 'aal2'.
416+
- `required_aal` (String) Required Authenticator Assurance Level for the settings flow: 'aal1' or 'highest_available'.
398417
- `session_cookie_persistent` (Boolean) Enable persistent session cookies (survive browser close).
399418
- `session_cookie_same_site` (String) SameSite cookie attribute (Lax, Strict, None).
400419
- `session_lifespan` (String) Session duration (e.g., '24h0m0s').
401420
- `session_tokenizer_templates` (Attributes Map) JWT tokenizer templates for the /sessions/whoami endpoint. Each key is a template name, and the value configures how JWTs are generated. (see [below for nested schema](#nestedatt--session_tokenizer_templates))
402421
- `session_whoami_required_aal` (String) Required AAL for session whoami endpoint: 'aal1', 'aal2', or 'highest_available'.
422+
- `settings_lifespan` (String) Lifespan of the settings flow (e.g., '30m0s'). Controls how long a settings flow session remains valid.
423+
- `settings_privileged_session_max_age` (String) Maximum age of a privileged session for the settings flow (e.g., '15m0s'). After this duration, the user must re-authenticate to make privileged changes like password updates.
403424
- `settings_ui_url` (String) URL for the account settings UI.
404425
- `smtp_connection_uri` (String, Sensitive) SMTP connection URI for sending emails.
405426
- `smtp_from_address` (String) Email address to send from.
406427
- `smtp_from_name` (String) Name to display as sender.
407428
- `smtp_headers` (Map of String) Custom headers to include in emails.
408429
- `totp_issuer` (String) TOTP issuer name shown in authenticator apps.
430+
- `verification_lifespan` (String) Lifespan of the verification flow (e.g., '30m0s'). Controls how long a verification flow session remains valid.
431+
- `verification_notify_unknown_recipients` (Boolean) When enabled, verification emails are sent even if the email address is not associated with any known identity.
409432
- `verification_ui_url` (String) URL for the verification UI.
433+
- `verification_use` (String) Verification method to use: 'code' (one-time code) or 'link' (magic link).
410434
- `webauthn_passwordless` (Boolean) Enable passwordless WebAuthn authentication.
411435
- `webauthn_rp_display_name` (String) WebAuthn Relying Party display name.
412436
- `webauthn_rp_id` (String) WebAuthn Relying Party ID (typically your domain).

examples/resources/ory_project_config/resource.tf

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,26 @@ resource "ory_project_config" "secure" {
3434
enable_oidc = true # Required for social providers (Google, GitHub, etc.)
3535
enable_oidc_auto_link_policy = true # Allow social providers with auto_link = true to link to existing identities
3636
enable_passkey = true
37+
enable_profile = true # Allow users to update profile traits via settings flow
38+
39+
# Code Method Configuration
40+
code_lifespan = "15m0s" # How long a code remains valid
41+
code_missing_credential_fallback_enabled = true # Use code as fallback when primary credential is missing
3742

3843
# Flow Controls
3944
enable_registration = true
4045
enable_recovery = true
4146
enable_verification = true
4247

48+
# Settings Flow
49+
settings_lifespan = "30m0s" # How long a settings flow session is valid
50+
settings_privileged_session_max_age = "15m0s" # Re-auth required for privileged changes after this duration
51+
52+
# Verification Flow
53+
verification_use = "code" # Use one-time code for verification (or "link")
54+
verification_lifespan = "30m0s" # How long a verification flow session is valid
55+
verification_notify_unknown_recipients = false # Don't send verification emails to unknown addresses
56+
4357
# MFA
4458
enable_totp = true
4559
totp_issuer = "MyApp"

internal/resources/projectconfig/build_patches_test.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,259 @@ func TestBuildPatches_NullLoginStyle(t *testing.T) {
220220
t.Error("expected no patch for null login_style")
221221
}
222222
}
223+
224+
func TestBuildPatches_EnableProfile(t *testing.T) {
225+
r := &ProjectConfigResource{}
226+
plan := &ProjectConfigResourceModel{
227+
EnableProfile: types.BoolValue(true),
228+
}
229+
230+
patches := r.buildPatches(context.Background(), plan)
231+
p := findPatch(patches, "/services/identity/config/selfservice/methods/profile/enabled")
232+
if p == nil {
233+
t.Fatal("expected a patch for enable_profile, got none")
234+
}
235+
if p.Op != "replace" {
236+
t.Errorf("expected op 'replace', got %q", p.Op)
237+
}
238+
if p.Value != true {
239+
t.Errorf("expected value true, got %v", p.Value)
240+
}
241+
}
242+
243+
func TestBuildPatches_NullEnableProfile(t *testing.T) {
244+
r := &ProjectConfigResource{}
245+
plan := &ProjectConfigResourceModel{
246+
EnableProfile: types.BoolNull(),
247+
}
248+
249+
patches := r.buildPatches(context.Background(), plan)
250+
p := findPatch(patches, "/services/identity/config/selfservice/methods/profile/enabled")
251+
if p != nil {
252+
t.Error("expected no patch for null enable_profile")
253+
}
254+
}
255+
256+
func TestBuildPatches_CodeLifespan(t *testing.T) {
257+
r := &ProjectConfigResource{}
258+
plan := &ProjectConfigResourceModel{
259+
CodeLifespan: types.StringValue("15m0s"),
260+
}
261+
262+
patches := r.buildPatches(context.Background(), plan)
263+
p := findPatch(patches, "/services/identity/config/selfservice/methods/code/config/lifespan")
264+
if p == nil {
265+
t.Fatal("expected a patch for code_lifespan, got none")
266+
}
267+
if p.Op != "replace" {
268+
t.Errorf("expected op 'replace', got %q", p.Op)
269+
}
270+
if p.Value != "15m0s" {
271+
t.Errorf("expected value '15m0s', got %v", p.Value)
272+
}
273+
}
274+
275+
func TestBuildPatches_NullCodeLifespan(t *testing.T) {
276+
r := &ProjectConfigResource{}
277+
plan := &ProjectConfigResourceModel{
278+
CodeLifespan: types.StringNull(),
279+
}
280+
281+
patches := r.buildPatches(context.Background(), plan)
282+
p := findPatch(patches, "/services/identity/config/selfservice/methods/code/config/lifespan")
283+
if p != nil {
284+
t.Error("expected no patch for null code_lifespan")
285+
}
286+
}
287+
288+
func TestBuildPatches_CodeMissingCredentialFallbackEnabled(t *testing.T) {
289+
r := &ProjectConfigResource{}
290+
plan := &ProjectConfigResourceModel{
291+
CodeMissingCredentialFallbackEnabled: types.BoolValue(true),
292+
}
293+
294+
patches := r.buildPatches(context.Background(), plan)
295+
p := findPatch(patches, "/services/identity/config/selfservice/methods/code/config/missing_credential_fallback_enabled")
296+
if p == nil {
297+
t.Fatal("expected a patch for code_missing_credential_fallback_enabled, got none")
298+
}
299+
if p.Op != "replace" {
300+
t.Errorf("expected op 'replace', got %q", p.Op)
301+
}
302+
if p.Value != true {
303+
t.Errorf("expected value true, got %v", p.Value)
304+
}
305+
}
306+
307+
func TestBuildPatches_NullCodeMissingCredentialFallbackEnabled(t *testing.T) {
308+
r := &ProjectConfigResource{}
309+
plan := &ProjectConfigResourceModel{
310+
CodeMissingCredentialFallbackEnabled: types.BoolNull(),
311+
}
312+
313+
patches := r.buildPatches(context.Background(), plan)
314+
p := findPatch(patches, "/services/identity/config/selfservice/methods/code/config/missing_credential_fallback_enabled")
315+
if p != nil {
316+
t.Error("expected no patch for null code_missing_credential_fallback_enabled")
317+
}
318+
}
319+
320+
func TestBuildPatches_SettingsLifespan(t *testing.T) {
321+
r := &ProjectConfigResource{}
322+
plan := &ProjectConfigResourceModel{
323+
SettingsLifespan: types.StringValue("30m0s"),
324+
}
325+
326+
patches := r.buildPatches(context.Background(), plan)
327+
p := findPatch(patches, "/services/identity/config/selfservice/flows/settings/lifespan")
328+
if p == nil {
329+
t.Fatal("expected a patch for settings_lifespan, got none")
330+
}
331+
if p.Op != "replace" {
332+
t.Errorf("expected op 'replace', got %q", p.Op)
333+
}
334+
if p.Value != "30m0s" {
335+
t.Errorf("expected value '30m0s', got %v", p.Value)
336+
}
337+
}
338+
339+
func TestBuildPatches_NullSettingsLifespan(t *testing.T) {
340+
r := &ProjectConfigResource{}
341+
plan := &ProjectConfigResourceModel{
342+
SettingsLifespan: types.StringNull(),
343+
}
344+
345+
patches := r.buildPatches(context.Background(), plan)
346+
p := findPatch(patches, "/services/identity/config/selfservice/flows/settings/lifespan")
347+
if p != nil {
348+
t.Error("expected no patch for null settings_lifespan")
349+
}
350+
}
351+
352+
func TestBuildPatches_SettingsPrivilegedSessionMaxAge(t *testing.T) {
353+
r := &ProjectConfigResource{}
354+
plan := &ProjectConfigResourceModel{
355+
SettingsPrivilegedSessionMaxAge: types.StringValue("15m0s"),
356+
}
357+
358+
patches := r.buildPatches(context.Background(), plan)
359+
p := findPatch(patches, "/services/identity/config/selfservice/flows/settings/privileged_session_max_age")
360+
if p == nil {
361+
t.Fatal("expected a patch for settings_privileged_session_max_age, got none")
362+
}
363+
if p.Op != "replace" {
364+
t.Errorf("expected op 'replace', got %q", p.Op)
365+
}
366+
if p.Value != "15m0s" {
367+
t.Errorf("expected value '15m0s', got %v", p.Value)
368+
}
369+
}
370+
371+
func TestBuildPatches_NullSettingsPrivilegedSessionMaxAge(t *testing.T) {
372+
r := &ProjectConfigResource{}
373+
plan := &ProjectConfigResourceModel{
374+
SettingsPrivilegedSessionMaxAge: types.StringNull(),
375+
}
376+
377+
patches := r.buildPatches(context.Background(), plan)
378+
p := findPatch(patches, "/services/identity/config/selfservice/flows/settings/privileged_session_max_age")
379+
if p != nil {
380+
t.Error("expected no patch for null settings_privileged_session_max_age")
381+
}
382+
}
383+
384+
func TestBuildPatches_VerificationUse(t *testing.T) {
385+
r := &ProjectConfigResource{}
386+
plan := &ProjectConfigResourceModel{
387+
VerificationUse: types.StringValue("code"),
388+
}
389+
390+
patches := r.buildPatches(context.Background(), plan)
391+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/use")
392+
if p == nil {
393+
t.Fatal("expected a patch for verification_use, got none")
394+
}
395+
if p.Op != "replace" {
396+
t.Errorf("expected op 'replace', got %q", p.Op)
397+
}
398+
if p.Value != "code" {
399+
t.Errorf("expected value 'code', got %v", p.Value)
400+
}
401+
}
402+
403+
func TestBuildPatches_NullVerificationUse(t *testing.T) {
404+
r := &ProjectConfigResource{}
405+
plan := &ProjectConfigResourceModel{
406+
VerificationUse: types.StringNull(),
407+
}
408+
409+
patches := r.buildPatches(context.Background(), plan)
410+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/use")
411+
if p != nil {
412+
t.Error("expected no patch for null verification_use")
413+
}
414+
}
415+
416+
func TestBuildPatches_VerificationLifespan(t *testing.T) {
417+
r := &ProjectConfigResource{}
418+
plan := &ProjectConfigResourceModel{
419+
VerificationLifespan: types.StringValue("30m0s"),
420+
}
421+
422+
patches := r.buildPatches(context.Background(), plan)
423+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/lifespan")
424+
if p == nil {
425+
t.Fatal("expected a patch for verification_lifespan, got none")
426+
}
427+
if p.Op != "replace" {
428+
t.Errorf("expected op 'replace', got %q", p.Op)
429+
}
430+
if p.Value != "30m0s" {
431+
t.Errorf("expected value '30m0s', got %v", p.Value)
432+
}
433+
}
434+
435+
func TestBuildPatches_NullVerificationLifespan(t *testing.T) {
436+
r := &ProjectConfigResource{}
437+
plan := &ProjectConfigResourceModel{
438+
VerificationLifespan: types.StringNull(),
439+
}
440+
441+
patches := r.buildPatches(context.Background(), plan)
442+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/lifespan")
443+
if p != nil {
444+
t.Error("expected no patch for null verification_lifespan")
445+
}
446+
}
447+
448+
func TestBuildPatches_VerificationNotifyUnknownRecipients(t *testing.T) {
449+
r := &ProjectConfigResource{}
450+
plan := &ProjectConfigResourceModel{
451+
VerificationNotifyUnknownRecipients: types.BoolValue(true),
452+
}
453+
454+
patches := r.buildPatches(context.Background(), plan)
455+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/notify_unknown_recipients")
456+
if p == nil {
457+
t.Fatal("expected a patch for verification_notify_unknown_recipients, got none")
458+
}
459+
if p.Op != "replace" {
460+
t.Errorf("expected op 'replace', got %q", p.Op)
461+
}
462+
if p.Value != true {
463+
t.Errorf("expected value true, got %v", p.Value)
464+
}
465+
}
466+
467+
func TestBuildPatches_NullVerificationNotifyUnknownRecipients(t *testing.T) {
468+
r := &ProjectConfigResource{}
469+
plan := &ProjectConfigResourceModel{
470+
VerificationNotifyUnknownRecipients: types.BoolNull(),
471+
}
472+
473+
patches := r.buildPatches(context.Background(), plan)
474+
p := findPatch(patches, "/services/identity/config/selfservice/flows/verification/notify_unknown_recipients")
475+
if p != nil {
476+
t.Error("expected no patch for null verification_notify_unknown_recipients")
477+
}
478+
}

0 commit comments

Comments
 (0)