diff --git a/.changeset/types-0-30-0.md b/.changeset/types-0-30-0.md new file mode 100644 index 00000000..b6c8a0c5 --- /dev/null +++ b/.changeset/types-0-30-0.md @@ -0,0 +1,8 @@ +--- +'seamless-auth-api': minor +--- + +Moves to `@seamless-auth/types` 0.30.0. + +- OAuth provider config is refused at request validation when `clientSecretEnv` does not end in `_CLIENT_SECRET` or a provider URL is not `http(s)`, the same rules 0.19.4 enforced after parsing. The production-only checks (https, public destinations) stay in the server. +- New system config keys `recovery_codes` (`RECOVERY_CODES`, default `{"enabled":true,"requireAtEnrollment":false}`) and `account_recovery` (`ACCOUNT_RECOVERY`, default `{"enabled":false,"delay":"24h"}`), seeded at boot and patchable. The recovery features that read them arrive in this release. diff --git a/docs/configuration.md b/docs/configuration.md index 30312f59..fbbdba64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -80,6 +80,8 @@ first boot. | `SESSION_IDLE_TTL` | No | `8h` | `session_idle_ttl` | Format `\d+[smhd]`. How long a session may go unrefreshed. The absolute session lifetime is `REFRESH_TOKEN_TTL`, measured from sign-in and not extended by refresh; this only binds while it is the shorter of the two. | | `MAX_CONCURRENT_SESSIONS` | No | unlimited | `max_concurrent_sessions` | How many sessions one user may hold at once. Unset, empty, `null`, `none` or `unlimited` all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a `session_evicted` auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10. | | `AUTHENTICATOR_POLICY` | No | `{"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"allow","aaguidAllowList":[],"aaguidDenyList":[]}` | `authenticator_policy` | JSON. `attachment` is `any`, `platform` or `cross-platform`; `any` offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. `userVerification` is `required`, `preferred` or `discouraged` and drives both what the browser is asked for and what the server enforces. `attestation` is `none` or `direct`; `direct` asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. `requireKnownAuthenticator` refuses an authenticator the metadata service does not list, and only applies under `direct`. It also refuses a credential that self attests or presents no attestation, since neither can be looked up, so it rules out authenticators that ship no attestation certificate. Changing `attestation` needs a restart, because the metadata service is prepared at startup. `syncedPasskeys` is `allow` or `block`; `allow`, the default, enrols a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey, so a stock instance accepts the passkey a normal device offers. `block` refuses those, which is what a deployment issuing its own authenticators wants. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. | +| `RECOVERY_CODES` | No | `{"enabled":true,"requireAtEnrollment":false}` | `recovery_codes` | JSON. Single-use recovery codes. A user has codes only after generating a set, so `enabled` matters once someone has. `requireAtEnrollment` asks for a set during first passkey enrollment. Recovery codes are not a login method, so `LOGIN_METHODS` cannot turn them off. | +| `ACCOUNT_RECOVERY` | No | `{"enabled":false,"delay":"24h"}` | `account_recovery` | JSON. Self-service recovery through a verified email or phone. Off by default. It completes only after `delay` (`1h` to `30d`), and every other channel and session is told so the owner can cancel. | #### `/login` response timing @@ -401,6 +403,8 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig | `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` | | `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` | | `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false,syncedPasskeys:"allow",aaguidAllowList:[],aaguidDenyList:[]}` | +| `recovery_codes` | object | `RECOVERY_CODES` | `{enabled:true,requireAtEnrollment:false}` | +| `account_recovery` | object | `ACCOUNT_RECOVERY` | `{enabled:false,delay:"24h"}` | | `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` | | `max_concurrent_sessions` | number or null | `MAX_CONCURRENT_SESSIONS` | `null` (no limit) | | `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - | diff --git a/openapi.json b/openapi.json index 1aac68d4..ee2f1049 100644 --- a/openapi.json +++ b/openapi.json @@ -5730,7 +5730,8 @@ "email": "string", "phone": "string", "ttl": 0, - "refreshTtl": 0 + "refreshTtl": 0, + "recovery": true }, "schema": { "type": "object", @@ -5745,7 +5746,8 @@ "email": { "type": "string" }, "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, - "refreshTtl": { "type": "number" } + "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" } }, "required": ["message"] } @@ -7377,6 +7379,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -8085,6 +8088,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "returnTo": null, "nextStep": null }, @@ -8101,6 +8105,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "returnTo": { "type": "string", "format": "uri" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, @@ -10764,6 +10769,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -10779,6 +10785,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -10914,6 +10921,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -10929,6 +10937,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11064,6 +11073,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11079,6 +11089,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11201,6 +11212,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11216,6 +11228,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -11675,6 +11688,7 @@ "phone": "string", "ttl": 0, "refreshTtl": 0, + "recovery": true, "nextStep": null }, "schema": { @@ -11690,6 +11704,7 @@ "phone": { "type": "string", "nullable": true }, "ttl": { "type": "number" }, "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, "nextStep": { "type": "string", "enum": ["enroll_passkey"] } }, "required": ["message"] @@ -12468,7 +12483,11 @@ "description": "HTTP 200", "content": { "application/json": { - "example": { "loginMethods": [null] }, + "example": { + "loginMethods": [null], + "recoveryCodes": true, + "accountRecovery": true + }, "schema": { "type": "object", "properties": { @@ -12479,7 +12498,9 @@ "enum": ["passkey", "magic_link", "email_otp", "phone_otp", "oauth"] }, "minItems": 1 - } + }, + "recoveryCodes": { "type": "boolean" }, + "accountRecovery": { "type": "boolean" } }, "required": ["loginMethods"] } @@ -12584,6 +12605,8 @@ "oauth_providers": null, "lockout_policy": null, "authenticator_policy": null, + "recovery_codes": null, + "account_recovery": null, "access_token_ttl": "string", "session_idle_ttl": null, "refresh_token_ttl": "string", @@ -12636,7 +12659,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -12763,6 +12789,22 @@ "aaguidDenyList": [] } }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + }, + "default": { "enabled": true, "requireAtEnrollment": false } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + }, + "default": { "enabled": false, "delay": "24h" } + }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "session_idle_ttl": { "type": "string", @@ -12968,7 +13010,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13079,6 +13124,20 @@ } } }, + "recovery_codes": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": true }, + "requireAtEnrollment": { "type": "boolean", "default": false } + } + }, + "account_recovery": { + "type": "object", + "properties": { + "enabled": { "type": "boolean", "default": false }, + "delay": { "type": "string", "pattern": "^\\d+[smhd]$", "default": "24h" } + } + }, "access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "session_idle_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, "refresh_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" }, @@ -13266,7 +13325,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13378,7 +13440,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13471,7 +13536,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13617,7 +13685,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean" }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, @@ -13689,7 +13760,10 @@ "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "enabled": { "type": "boolean", "default": true }, "clientId": { "type": "string", "minLength": 1 }, - "clientSecretEnv": { "type": "string", "minLength": 1 }, + "clientSecretEnv": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,59}_CLIENT_SECRET$" + }, "authorizationUrl": { "type": "string", "format": "uri" }, "tokenUrl": { "type": "string", "format": "uri" }, "userInfoUrl": { "type": "string", "format": "uri" }, diff --git a/package-lock.json b/package-lock.json index c6721be7..c62314a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "seamless-auth-api", - "version": "0.19.1", + "version": "0.19.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-auth-api", - "version": "0.19.1", + "version": "0.19.4", "license": "Apache-2.0", "dependencies": { "@asteasolutions/zod-to-openapi": "^9.1.0", "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -3093,9 +3093,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", - "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.30.0.tgz", + "integrity": "sha512-1MM/jBSG901HS35pLCNhEcOjA1KYLcBIxQZHYyexOOQ/7R8VNjxlmxG0L26VPV+FhA7Rkq5sBIifCLbR+g+0zA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 34ea9819..f28dbd72 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.2.0", "@seamless-auth/messaging-aws": "^0.2.0", "@seamless-auth/messaging-twilio": "^0.2.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/src/config/systemConfig.defaults.ts b/src/config/systemConfig.defaults.ts index ddb8a354..f742fc7a 100644 --- a/src/config/systemConfig.defaults.ts +++ b/src/config/systemConfig.defaults.ts @@ -4,7 +4,12 @@ * See LICENSE file in the project root for full license information */ -import { AuthenticatorPolicySchema, DefaultFlowRateLimits } from '@seamless-auth/types'; +import { + AccountRecoveryPolicySchema, + AuthenticatorPolicySchema, + DefaultFlowRateLimits, + RecoveryCodesPolicySchema, +} from '@seamless-auth/types'; import type { SystemConfig } from '../schemas/systemConfig.schema.js'; @@ -24,6 +29,8 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial = { // Manager passkey and so failed the first registration on a stock install. Naming it // seeds the right value on either version. Drop it once the floor is 0.19.0. authenticator_policy: AuthenticatorPolicySchema.parse({ syncedPasskeys: 'allow' }), + recovery_codes: RecoveryCodesPolicySchema.parse({}), + account_recovery: AccountRecoveryPolicySchema.parse({}), session_idle_ttl: '8h', // No cap unless a deployment asks for one, so nothing changes for an instance // that predates the key. diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index ae29887c..f7fdab48 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -11,6 +11,8 @@ export const SYSTEM_CONFIG_ENV_MAP = { oauth_providers: 'OAUTH_PROVIDERS', lockout_policy: 'LOCKOUT_POLICY', authenticator_policy: 'AUTHENTICATOR_POLICY', + recovery_codes: 'RECOVERY_CODES', + account_recovery: 'ACCOUNT_RECOVERY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', phishing_resistant_only: 'PHISHING_RESISTANT_ONLY', diff --git a/src/generated/api.ts b/src/generated/api.ts index 11ae4828..2f913319 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -5521,7 +5521,8 @@ export interface paths { * "email": "string", * "phone": "string", * "ttl": 0, - * "refreshTtl": 0 + * "refreshTtl": 0, + * "recovery": true * } */ 'application/json': { @@ -5536,6 +5537,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; }; }; }; @@ -7315,6 +7317,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; } @@ -8101,6 +8104,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "returnTo": null, * "nextStep": null * } @@ -8116,6 +8120,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** Format: uri */ returnTo?: string; /** @enum {string} */ @@ -10780,6 +10785,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -10794,6 +10800,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -10954,6 +10961,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -10968,6 +10976,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11128,6 +11137,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11142,6 +11152,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11284,6 +11295,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11298,6 +11310,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -11788,6 +11801,7 @@ export interface paths { * "phone": "string", * "ttl": 0, * "refreshTtl": 0, + * "recovery": true, * "nextStep": null * } */ @@ -11802,6 +11816,7 @@ export interface paths { phone?: string | null; ttl?: number; refreshTtl?: number; + recovery?: boolean; /** @enum {string} */ nextStep?: 'enroll_passkey'; }; @@ -12706,11 +12721,15 @@ export interface paths { * @example { * "loginMethods": [ * null - * ] + * ], + * "recoveryCodes": true, + * "accountRecovery": true * } */ 'application/json': { loginMethods: ('passkey' | 'magic_link' | 'email_otp' | 'phone_otp' | 'oauth')[]; + recoveryCodes?: boolean; + accountRecovery?: boolean; }; }; }; @@ -12882,6 +12901,8 @@ export interface paths { * "oauth_providers": null, * "lockout_policy": null, * "authenticator_policy": null, + * "recovery_codes": null, + * "account_recovery": null, * "access_token_ttl": "string", * "session_idle_ttl": null, * "refresh_token_ttl": "string", @@ -13010,6 +13031,30 @@ export interface paths { /** @default [] */ aaguidDenyList: string[]; }; + /** + * @default { + * "enabled": true, + * "requireAtEnrollment": false + * } + */ + recovery_codes: { + /** @default true */ + enabled: boolean; + /** @default false */ + requireAtEnrollment: boolean; + }; + /** + * @default { + * "enabled": false, + * "delay": "24h" + * } + */ + account_recovery: { + /** @default false */ + enabled: boolean; + /** @default 24h */ + delay: string; + }; access_token_ttl: string; /** @default 8h */ session_idle_ttl: string; @@ -13246,6 +13291,18 @@ export interface paths { /** @default [] */ aaguidDenyList?: string[]; }; + recovery_codes?: { + /** @default true */ + enabled?: boolean; + /** @default false */ + requireAtEnrollment?: boolean; + }; + account_recovery?: { + /** @default false */ + enabled?: boolean; + /** @default 24h */ + delay?: string; + }; access_token_ttl?: string; session_idle_ttl?: string; refresh_token_ttl?: string; diff --git a/src/utils/parseEnvConfigs.ts b/src/utils/parseEnvConfigs.ts index e26daf26..44c3df9d 100644 --- a/src/utils/parseEnvConfigs.ts +++ b/src/utils/parseEnvConfigs.ts @@ -27,6 +27,8 @@ export function parseSystemConfigEnvValue(key: keyof typeof SYSTEM_CONFIG_ENV_MA case 'lockout_policy': case 'authenticator_policy': case 'flow_rate_limits': + case 'recovery_codes': + case 'account_recovery': return JSON.parse(raw); case 'rate_limit': diff --git a/tests/integration/systemConfig/oauthProviders.spec.ts b/tests/integration/systemConfig/oauthProviders.spec.ts index ebc3ceb7..9c9b1a15 100644 --- a/tests/integration/systemConfig/oauthProviders.spec.ts +++ b/tests/integration/systemConfig/oauthProviders.spec.ts @@ -105,9 +105,24 @@ describe('POST /system-config/oauth-providers', () => { .post('/system-config/oauth-providers') .send(buildProvider({ id: 'acme', clientSecretEnv: 'SEAMLESS_SERVICE_TOKEN' })); + expect(res.status).toBe(400); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + // The shared schema holds the scheme and secret name rules, so this is the case only + // the server's own policy can refuse: http is a valid URL, but not in production. + it('refuses an http provider URL in production', async () => { + mockConfigWithProviders([]); + vi.stubEnv('NODE_ENV', 'production'); + + const res = await request(app) + .post('/system-config/oauth-providers') + .send(buildProvider({ id: 'acme', tokenUrl: 'http://idp.example.com/token' })); + + vi.unstubAllEnvs(); expect(res.status).toBe(400); expect(res.body.error).toBe('Invalid OAuth provider'); - expect(res.body.details[0]).toMatch(/clientSecretEnv/); + expect(res.body.details).toEqual(['tokenUrl must use https']); expect(SystemConfig.upsert).not.toHaveBeenCalled(); }); @@ -167,7 +182,20 @@ describe('PATCH /system-config/oauth-providers/:id', () => { .send({ clientSecretEnv: 'DATABASE_URL' }); expect(res.status).toBe(400); - expect(res.body.details[0]).toMatch(/clientSecretEnv/); + expect(SystemConfig.upsert).not.toHaveBeenCalled(); + }); + + it('refuses an update to an http provider URL in production', async () => { + mockConfigWithProviders([buildProvider()]); + vi.stubEnv('NODE_ENV', 'production'); + + const res = await request(app) + .patch('/system-config/oauth-providers/google') + .send({ userInfoUrl: 'http://idp.example.com/userinfo' }); + + vi.unstubAllEnvs(); + expect(res.status).toBe(400); + expect(res.body.details).toEqual(['userInfoUrl must use https']); expect(SystemConfig.upsert).not.toHaveBeenCalled(); }); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index af186893..7f18bc73 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -82,6 +82,7 @@ describe('PATCH /system-config/admin', () => { }); it('refuses OAuth providers that fail the provider policy', async () => { + vi.stubEnv('NODE_ENV', 'production'); (User.findAll as any).mockResolvedValue([]); (SystemConfig.findAll as any).mockResolvedValue([]); @@ -94,18 +95,19 @@ describe('PATCH /system-config/admin', () => { name: 'Acme', enabled: true, clientId: 'client', - clientSecretEnv: 'DB_PASSWORD', + clientSecretEnv: 'ACME_CLIENT_SECRET', authorizationUrl: 'https://idp.example.com/authorize', - tokenUrl: 'https://idp.example.com/token', + tokenUrl: 'http://idp.example.com/token', userInfoUrl: 'https://idp.example.com/userinfo', scopes: ['openid'], }, ], }); + vi.unstubAllEnvs(); expect(res.status).toBe(400); expect(res.body.error).toBe('Invalid OAuth provider'); - expect(res.body.details[0]).toMatch(/^acme: clientSecretEnv/); + expect(res.body.details).toEqual(['acme: tokenUrl must use https']); expect(SystemConfig.upsert).not.toHaveBeenCalled(); }); diff --git a/tests/unit/config/systemConfigEnvMap.spec.ts b/tests/unit/config/systemConfigEnvMap.spec.ts index f9800bf1..e4fde571 100644 --- a/tests/unit/config/systemConfigEnvMap.spec.ts +++ b/tests/unit/config/systemConfigEnvMap.spec.ts @@ -19,6 +19,8 @@ describe('SYSTEM_CONFIG_ENV_MAP', () => { oauth_providers: 'OAUTH_PROVIDERS', lockout_policy: 'LOCKOUT_POLICY', authenticator_policy: 'AUTHENTICATOR_POLICY', + recovery_codes: 'RECOVERY_CODES', + account_recovery: 'ACCOUNT_RECOVERY', passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED', prompt_passkey_enrollment: 'PROMPT_PASSKEY_ENROLLMENT', phishing_resistant_only: 'PHISHING_RESISTANT_ONLY',