Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/spotty-otters-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'seamless-auth-api': minor
---

Bound how many sessions one user may hold at once.

`max_concurrent_sessions` defaults to no limit, so an instance that predates the
setting is unaffected. Unlimited is `null` rather than `0`, and `0` is refused,
because zero would otherwise read as "no sessions allowed" and lock every user
out of a deployment that meant to remove the cap. `MAX_CONCURRENT_SESSIONS`
accepts a number, or an empty value, `null`, `none` or `unlimited` for no cap,
since a deployment template cannot easily unset a variable.

At the limit a sign-in **succeeds** and the user's oldest session is revoked with
`revokedReason: 'concurrent_session_limit'`, recorded as a new `session_evicted`
auth event naming the session that ended. Refusing the new session instead would
lock a user out of the device in front of them until something they may not have
access to expires, which for the shared workstations this exists to protect is
the common case rather than the edge one.

Enforcement runs before the new session row is created, so the limit counts the
session about to exist: at a limit of 3 a user holding 3 ends up with 3, not 4.
Lowering the limit leaves users above it, and each converges on their next
sign-in, which evicts everything above the cap in one pass. It never throws: a
session that cannot be revoked is logged and the sign-in continues, because
failing an authentication over a housekeeping step is worse than briefly
exceeding the cap.

NIST 800-53 AC-10. Requires `@seamless-auth/types` 0.17.0, which publishes the
config key.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ REFRESH_TOKEN_TTL=1d
# REFRESH_TOKEN_TTL above; this only binds while it is the shorter of the two.
# Government deployments typically want 15m to 30m here.
SESSION_IDLE_TTL=8h
# How many sessions one user may hold at once. Empty, "null", "none" or "unlimited"
# all mean no cap, which is the default. At the limit, a new sign-in revokes the
# user's oldest session rather than being refused, and records a session_evicted
# auth event. Government deployments and shared workstations usually want a small
# number here.
MAX_CONCURRENT_SESSIONS=unlimited
RATE_LIMIT=100
DELAY_AFTER=50
# Testing escape hatch. When true, all auth rate limiters (global, OTP, magic link,
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ first boot.
| `PASSKEY_LOGIN_FALLBACK_ENABLED` | No | `true` | `passkey_login_fallback_enabled` | When `false`, an account that holds a passkey continues with passkey only. The client's `passkeyAvailable` hint on `POST /login` cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. |
| `LOCKOUT_POLICY` | No | `{"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900}` | `lockout_policy` | JSON. Set `enabled:false` only when an upstream policy handles lockout. |
| `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`; 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":"block","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 `block` or `allow`; `block`, the default, refuses a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey. `aaguidAllowList` and `aaguidDenyList` restrict which authenticator models may register, and need `attestation` set to `direct` to mean anything. |

### Service tokens and secrets
Expand Down Expand Up @@ -271,6 +272,7 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig
| `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}` |
| `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` | - |
| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - |
| `rate_limit` | integer > 0 | `RATE_LIMIT` | - |
Expand Down
29 changes: 29 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,32 @@ that is not one discloses part of the allowlist and helps the browser not at all
The refusal is recorded as one `request_suspicious` auth event with the real client address and user
agent, and the rejected origin in an `origin` metadata field. It used to be recorded with the origin
string in the `ipAddress` field, which made the trail hard to read.

## Concurrent sessions per user

**Posture: uncapped by default, and a cap evicts rather than refuses.**

`max_concurrent_sessions` bounds how many sessions one user may hold at once. It
defaults to no limit, so an instance that predates the setting is unaffected.
Unlimited is `null` rather than `0`, and the schema refuses `0`, because zero
would otherwise read as "no sessions allowed" and lock every user out of a
deployment that meant to remove the cap.

When a signed-in user is at the limit, the new sign-in **succeeds** and their
oldest session is revoked with `revokedReason: 'concurrent_session_limit'`, and a
`session_evicted` auth event names the session that ended. Refusing the new
session instead would lock a user out of the device in front of them until
something they may not have access to expires, which for the shared workstations
this setting exists to protect is the common case rather than the edge one.

Lowering the limit leaves users above it. Each converges on their next sign-in,
which evicts everything above the cap in one pass rather than shedding one
session per login indefinitely.

Enforcement runs before the new session row is created, so the limit counts the
session about to exist: at a limit of 3, a user holding 3 ends up with 3, not 4.
It never throws. A session that cannot be revoked is logged and the sign-in
continues, because failing an authentication over a housekeeping step is worse
than briefly exceeding the cap.

This is NIST 800-53 AC-10.
16 changes: 16 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,7 @@
"service_token_failed",
"service_token_rotated",
"service_token_success",
"session_evicted",
"step_up_challenge",
"step_up_failed",
"step_up_success",
Expand Down Expand Up @@ -1608,6 +1609,7 @@
"service_token_failed",
"service_token_rotated",
"service_token_success",
"session_evicted",
"step_up_challenge",
"step_up_failed",
"step_up_success",
Expand Down Expand Up @@ -7658,6 +7660,7 @@
"access_token_ttl": "string",
"session_idle_ttl": null,
"refresh_token_ttl": "string",
"max_concurrent_sessions": null,
"rate_limit": 0,
"delay_after": 0,
"rpid": "string",
Expand Down Expand Up @@ -7828,6 +7831,13 @@
"default": "8h"
},
"refresh_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" },
"max_concurrent_sessions": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true,
"default": null
},
"rate_limit": { "type": "integer", "minimum": 0, "exclusiveMinimum": true },
"delay_after": { "type": "integer", "minimum": 0 },
"rpid": { "type": "string", "minLength": 1 },
Expand Down Expand Up @@ -8034,6 +8044,12 @@
"access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" },
"session_idle_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" },
"refresh_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" },
"max_concurrent_sessions": {
"type": "integer",
"nullable": true,
"minimum": 0,
"exclusiveMinimum": true
},
"rate_limit": { "type": "integer", "minimum": 0, "exclusiveMinimum": true },
"delay_after": { "type": "integer", "minimum": 0 },
"rpid": { "type": "string", "minLength": 1 },
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"@seamless-auth/messaging": "^0.1.0",
"@seamless-auth/messaging-aws": "^0.1.0",
"@seamless-auth/messaging-twilio": "^0.1.0",
"@seamless-auth/types": "^0.15.0",
"@seamless-auth/types": "^0.17.0",
"@simplewebauthn/server": "^13.3.3",
"base64url": "^3.0.1",
"bcrypt-ts": "^7.1.0",
Expand Down
44 changes: 33 additions & 11 deletions src/config/bootstrapSystemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ import { SYSTEM_CONFIG_ENV_MAP } from './systemConfig.envMap.js';

const logger = getLogger('bootstrapSystemConfig');

/**
* `system_config.value` is `JSONB NOT NULL`, so a config value of `null` has no
* row to live in. Absence is how the store spells it instead: every nullable key
* carries a schema default of `null`, so "no row" and "null" resolve to the same
* configuration.
*
* A key that was set and is now null therefore has its row removed, rather than
* an update that the column would refuse.
*/
function isStorable(value: unknown): boolean {
return value !== null && value !== undefined;
}

export async function bootstrapSystemConfig() {
const resolvedConfig: Record<string, unknown> = {};

Expand All @@ -37,7 +50,12 @@ export async function bootstrapSystemConfig() {
`the row is not marked admin-managed (updatedBy IS NULL). If this value was changed ` +
`through the admin console, that change is being reverted here.`,
);
await existing.update({ value: parsed });

if (isStorable(parsed)) {
await existing.update({ value: parsed });
} else {
await existing.destroy();
}
}

resolvedConfig[key] = parsed;
Expand All @@ -58,23 +76,27 @@ export async function bootstrapSystemConfig() {
);
}

await SystemConfig.create({
key,
value: defaultValue,
updatedBy: null,
});
if (isStorable(defaultValue)) {
await SystemConfig.create({
key,
value: defaultValue,
updatedBy: null,
});
}

resolvedConfig[key] = defaultValue;
continue;
}

const parsed = parseSystemConfigEnvValue(key as keyof typeof SYSTEM_CONFIG_ENV_MAP, envValue);

await SystemConfig.create({
key,
value: parsed,
updatedBy: null,
});
if (isStorable(parsed)) {
await SystemConfig.create({
key,
value: parsed,
updatedBy: null,
});
}

resolvedConfig[key] = parsed;
}
Expand Down
3 changes: 3 additions & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
// the default the schema gives it instead of being absent here until someone notices.
authenticator_policy: AuthenticatorPolicySchema.parse({}),
session_idle_ttl: '8h',
// No cap unless a deployment asks for one, so nothing changes for an instance
// that predates the key.
max_concurrent_sessions: null,
passkey_login_fallback_enabled: true,
};
1 change: 1 addition & 0 deletions src/config/systemConfig.envMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const SYSTEM_CONFIG_ENV_MAP = {
passkey_login_fallback_enabled: 'PASSKEY_LOGIN_FALLBACK_ENABLED',
access_token_ttl: 'ACCESS_TOKEN_TTL',
session_idle_ttl: 'SESSION_IDLE_TTL',
max_concurrent_sessions: 'MAX_CONCURRENT_SESSIONS',
refresh_token_ttl: 'REFRESH_TOKEN_TTL',
rate_limit: 'RATE_LIMIT',
delay_after: 'DELAY_AFTER',
Expand Down
Loading
Loading