Skip to content

Releases: cboxdk/laravel-id

v0.87.3 — two authentication defects

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 04 Aug 00:01

Two proven defects, both silent, both found by a platform review.

A deactivated subject authenticated with ANY password. DatabaseAccountMembers::verifyPassword() refused a deactivated subject with ! $this->hasher->check($password, $this->dummyHash()). The dummy verify exists to burn time and have its result discarded; the negation made it the answer. A dummy hash matches nothing, so check() returned false and !false authenticated.

"Deactivated subject holding an active membership" is not an exotic state: it is an unaccepted invitation, and it is a removed member. The caller mints a session on that answer and clears the failure counters, so the session goes live the moment the subject is reactivated.

It survived because the branch is only reached with a WRONG password by an attacker — every honest test supplies the right one, where the broken and fixed code agree. The refusal is now a statement block that cannot return the discarded value, matching DatabasePlatformOperators::verifyPassword(), which always expressed the same policy correctly.

SCIM: a group PATCH with a bare members[...] value filter detached every member. members[value eq "x"].display was refused; members[value eq "x"] was not — it contains no ]. and does not begin with members., so it cleared both guards, reached sync(valueIds(…)) with a non-list value, and emptied the group. 200 returned, membership-changed fired, every role mapped from that group was revoked for every member, and the connector recorded a success so it never retried or re-synced.

add and replace now refuse any filtered members path. remove with that exact path is how an IdP detaches one member and continues to work.

Both falsified: restoring either defect turns its test red.

v0.87.2 — revoke on member credential writes

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 23:28

A member's password reset no longer leaves their other sessions alive.

The security stamp on account_members invalidated MEMBER sessions, and that was the whole of log-out-everywhere while a member session existed. It is a host's decision to stop keeping one — the credential of record is the subject, so a second session for the same person is a second place to ask who they are — and the moment a host makes that decision, the control silently stops covering anything: Subjects::setPassword() does not revoke.

resetPassword() now revokes the subject's sessions inside its existing transaction. The stamp still moves; its other job, making a reset link single-use, is a different question and is unchanged.

activate() revokes too. Removing a member deactivates their subject without revoking its sessions, so accepting a later invitation resurrected the old ones beside a freshly-replaced password.

Falsified both ways: deleting either revocation turns its test red.

v0.87.1 — carry existing operators across

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 21:29

An operator carried across from before the subject unification could no longer sign in.

0.87.0 left the attaching to verifyPassword(): the local hash stayed the credential and the subject was created on that operator's next successful sign-in — the only moment the plaintext is available to seed one. That is correct while a sign-in exists which verifies against the local hash.

It stops being correct the moment a host makes operator authority a permission on the ordinary sign-in and retires the separate operator login form — which is what 0.87.0 exists to enable. That form was the only caller reaching the bootstrap window. On an upgraded deployment every existing operator then has no subject, no account to sign in as, and no door that consults their hash: locked out of the platform they run, by an upgrade that reports success.

A migration now attaches a subject to every operator that lacks one. The plaintext is gone but the hash is not, and it needs no re-deriving — both tables hash with the configured driver and both models pass an already-hashed value through untouched — so the credential moves and the password keeps working.

An operator who is also an account member is pointed at the subject they already have rather than given a second one, and their live password is left alone. The address is not marked verified: nothing here proves control of it.

A migration rather than a command, deliberately. A command is a step someone has to know about, and the failure mode for not knowing is that nobody can administer the platform — discovered after the deploy, by the person who can no longer fix it.

v0.87.0 — the operator is a person

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 20:45

A platform operator is no longer a second credential store.

platform_operators held an email and a bcrypt hash and nothing else. Everything that protects a sign-in on this platform lives on the SUBJECT — password policy, breached-password refusal, lockout, TOTP, passkeys, step-up, session revocation — and an operator had none of it. The widest reach in the product sat behind the weakest door, and it was weakest precisely because it was separate.

platform_operators.subject_id now points at an ordinary subject in the platform root, and verifyPassword() asks that subject. Account members already worked this way.

PlatformOperators::findBySubject() completes the change: with the operator unified onto the subject store, "is this session staff" is a question about the session a host already has — so a console can gate the platform pages as a permission instead of standing up a second sign-in beside the first. Suspended operators are excluded inside the lookup, because authority now rides an existing session and suspending an operator has never revoked their subject sessions.

Nothing breaks on upgrade: the column is nullable, the local hash remains the credential for an operator created before a platform root existed, and the subject attaches on that operator's next successful sign-in.

Full notes in CHANGELOG.md.

v0.86.0 — one provider catalogue, with capabilities

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 18:40

Providers were named in two registries that shared nothing but a word: ProviderCatalog held eleven entries for sign-in, DirectoryProvider held three for user sync, and Google and Entra were in both with nothing connecting them.

The administrator paid for that. The directory screen could not show the guide that already existed for the same provider, so somebody who had just finished connecting Google for sign-in was handed an empty credential form and left to work out alone that a directory needs a service account with domain-wide delegation rather than the OAuth client they had just made.

Added

  • A ProviderTemplate now carries what it can DO — ProviderCapability::Login, ProviderCapability::Directory — as a typed ProviderCapabilities set, with ProviderCatalog::withCapability() and ProviderCatalog::forDirectory(). Capabilities are derived from the entry's contents, never declared beside them: a hand-written list is a claim that can be false.
  • DirectorySetup carries the directory guide — steps, vendor documentation, and the credentials the connector actually reads — separately from the login guide, because they describe unrelated jobs. The declared credentials are driven through the real connectors in the suite: the full set must satisfy them, and dropping any single one must not.

Fixed

  • The Entra directory guidance asked for half the permissions it needs. The pull fetches groups as well as users, so User.Read.All alone got an administrator users and silently no groups. Both the connector's documentation and the catalogue entry now require Group.Read.All beside it, and the Google steps spell out that domain-wide delegation needs both read-only scopes.

Notes

  • DirectoryProvider stays as the persistence type for directories.provider — a stored column is a serialization boundary, and nothing about existing rows changes.
  • SCIM is deliberately not in the catalogue: it is a protocol the customer's identity provider speaks TO us, with no issuer, no vendor and no third-party documentation to link.
  • Additive throughout — one trailing optional constructor argument, no existing signature changed.

Full detail in CHANGELOG.md.

v0.85.0 — a refresh renews the ID Token

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 17:56

Fixed

A refresh renews the ID Token, not only the access token (OIDC Core §12.2).

The refresh grant returned access_token + refresh_token and nothing else. That is invisible for as long as every relying party authenticates the ACCESS token — and a hard failure for one that authenticates the ID Token, because the credential it actually presents could not be renewed at all. kubectl oidc-login is exactly that relying party, as are Grafana and Vault in that mode.

Measured against a live deployment: a client with a 300-second lifetime meant a browser window every five minutes, which is the behaviour offline_access exists to prevent — and the reason somebody then asks for a longer lifetime instead of a fix.

The refreshed token keeps iss, sub and aud from the original, stamps a fresh iat, and binds at_hash to the access token returned beside it. A grant with no user behind it (client_credentials) still gets none: an ID Token asserts an authenticated person, and one minted for a machine grant would be an assertion about nobody.

It carries no nonce. A nonce binds an ID Token to one authentication request so a client can detect replay, and a refresh is not an authentication request — echoing the original would hand back a token the client has already seen that nonce on, which is the condition its replay check exists to catch.

Added

The rotation family remembers the login it descends from.

auth_time and amr are recorded on the refresh token, so a refreshed ID Token can describe the ORIGINAL authentication as §12.2 requires rather than the moment it was refreshed. Without it a session's asserted assurance level would fall at its first refresh, which reads to a relying party gating on acr as the user losing their second factor.

Adds a nullable auth_time and amr to oauth_refresh_tokens. Families issued before this keep working and simply carry no authentication context — the claims are optional, and a missing one is honest where a fabricated one is not. The rollback is proven in both directions rather than inferred from up() having succeeded.

Upgrading

RefreshTokens::issue() takes $authTime and $amr as trailing optional arguments. Callers are unaffected; a host that has implemented the contract itself must widen its signature.

v0.84.0 — the host can contribute checks to cbox-id:doctor

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 16:28

Added

cbox-id:doctor now runs checks the host application registers.

The doctor knows what the library needs — extensions, a crypto key, signing keys, an issuer that resolves. It cannot know what the host needs, and the host's misconfigurations are the ones that fail quietly: a deployment claiming a shape it cannot serve still boots, still answers, and degrades behaviour with no error anywhere.

A host implements Console\\Contracts\\HealthCheck and adds it to the HealthChecks registry from a service provider. One command rather than a second health command — two things to remember to run means the one nobody runs is the one holding the finding.

Results are a typed HealthResult (ok / warn / fail, plus a label and the fix) rather than a string-keyed array, because this crosses a package boundary now.

A check that throws is reported as a failure and the rest still run. The contract forbids throwing; the registry assumes it happens anyway, because the moment you most want a health report is when something is already broken enough to throw.

v0.83.1 — DNS pinning survives dual-stack hosts

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 13:03

Fixed

Requires cboxdk/laravel-ssrf ^1.1.1. Below that version the SSRF guard pinned only the LAST of a host's validated addresses — curl treats a repeated CURLOPT_RESOLVE entry for the same host:port as a replacement, not an addition — so any dual-stack federation target whose AAAA sorted last was reached over IPv6 alone, and failed outright on a host with no IPv6 route. accounts.google.com is such a target.

Every outbound path in this package goes through that guard: OIDC discovery, token exchange, JWKS retrieval, and the directory connectors.

The floor is the fixed version rather than ^1.1, because a consumer that resolved 1.1.0 would get a package whose pinning silently discards addresses.

v0.83.0 — catalogue-backed connections and OAuth 2.0

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 11:06

Added

  • ConnectionType::OAuth2 for providers that speak OAuth 2.0 and nothing more (GitHub, Discord, Facebook). Kept separate from OIDC because the difference is not configuration: no id_token, no discovery, no signature over the claims. oauth2Config() reads it and refuses a config naming an OIDC provider.

  • A provider column on connections, plus catalogueProvidersFor(). A tenant may enable several catalogue providers at once while forOrganization() keeps answering "the organization's enterprise sign-on connection". Without the column the first active row won whichever it happened to be — so enabling Google could silently become an organization's SSO. create() refuses a key the catalogue does not have.

  • AppleClientSecret — Apple's client secret is an ES256 JWT minted from a downloaded signing key, not a string anyone can paste. Minted on demand, cached for an hour rather than Apple's six-month ceiling, and keyed by the material so rotating a key takes effect immediately. Verified in tests against a real EC public key.

See CHANGELOG.md for the full entry.

v0.82.1 — a client's token lifetime now reaches the id_token

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 03 Aug 09:48

Fixed

A client's token lifetime was hardcoded at 900 seconds for the id_token, so accessTokenTtl shortened only the access token — and a relying party that authenticates the ID token never sees that one.

Kubernetes is exactly that case: kubectl oidc-login presents the id_token as its bearer, the API server validates exp offline and never calls back, so for it the id_token's lifetime IS the revocation window. A client registered with a 300-second TTL was getting five minutes on a credential it does not present and fifteen on the one it does.

Nothing changes for a deployment that has configured nothing: the default is still 900.