> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/identity/audit-and-oidc.md).

# Identity audit and OpenID Connect

session.signed\_in, session.sign\_in\_failed (with success=False, storing only the attempted address and the IP — never the submitted password and nothing that says whether the…

## 1.13 Audit coverage for identity <a href="#id-113-audit-coverage-for-identity" id="id-113-audit-coverage-for-identity"></a>

`session.signed_in`, `session.sign_in_failed` (with `success=False`, storing only the attempted address and the IP — never the submitted password and nothing that says whether the account exists), `session.signed_out`, `session.revoked`, `password.reset_requested`, `password.reset_completed`, `password.emergency_reset`, `account.placeholder_claimed`, `USER_CLIENT_ACCOUNT_CREATED`, `USER_CLIENT_ACCOUNT_DELETED`, `USER_STAFF_ACCOUNT_CREATED`, `USER_SIGN_IN_EMAIL_CHANGED`, `USER_PROFILE_UPDATED`, `MFA_RESET_BY_ADMIN`, `MFA_CHALLENGE_FAILED`, `oidc.provider_configured`, `oidc.provider_removed`, `oidc.sign_in_failed`, and the organization lifecycle events. Without the failure event a lockout or a credential-stuffing campaign left no trace at all.

## 1.14 OpenID Connect <a href="#id-114-openid-connect" id="id-114-openid-connect"></a>

An organization whose owner has a **custom email domain** can connect an OpenID Connect provider, Tailscale-style. The domain is unique on the platform. Personal inbox providers (gmail, outlook, and the rest of `PUBLIC_EMAIL_DOMAINS`) cannot be claimed.

Setup lives on Organization → Sign-in (`/client/organizations/<id>/sign-in/`) only. `/account/sign-in/openid/` is sign-in for a domain that already has a verified provider; it does not register one. WebFinger at `https://{domain}/.well-known/webfinger` must advertise the issuer (`rel=http://openid.net/specs/connect/1.0/issuer`). The owner pastes the client ID and secret, authenticates at the provider with an email on that domain, and the provider is stored only after that round trip (`verified_at`). The callback is `/account/oidc/callback/`. Client secrets are Fernet-encrypted at rest.

Sign-in: Continue on `/account/sign-in/` looks up a verified provider for the email domain and redirects. The ID token email must match the address entered and sit on the claimed domain. Existing *client* accounts are signed in and linked (`OidcIdentity`); staff cannot use OpenID. A new address creates a client account with an unusable password. If the existing client already has MFA, that second factor is still required before the identity is stored. An existing `(user, issuer)` row is not rewritten onto a different `sub`. `email_verified` on the ID token is not a sign-in gate: an unverified address still signs in, and the notification bell shows “Please confirm your email” until the address is confirmed (OpenID `email_verified=true` or the emailed link). OpenID-only accounts are not placeholders — `is_claimable_placeholder_account` excludes a row that has an `OidcIdentity`. Invitations accept either a usable password or an OpenID identity.

The authorization-code round trip keeps its pending state in the Django session. A GET of `/account/sign-in/` still abandons a pending *password* MFA challenge, but it must not drop an in-flight OpenID attempt — Continue has already left this origin, and a prerender or second tab used to clear the state so the callback had nothing to finish.

**The redirect URI is the hostname the attempt began on**, because that session is scoped to one host. `callback_url` answers with a dedicated hostname (`AdminSiteConfig`, `TicketingConfig`) when the request arrived on one, and the canonical public site otherwise. A sign-in started on the admin domain and answered on the public site arrives with **no pending attempt at all** — `pending_oidc` returns None, which `oidc_callback` cannot tell from a forged callback, so the operator is bounced to sign-in with the generic failure. That is the whole bug, and it is why this varies.

It is still **never** `request.build_absolute_uri` outside DEBUG: the Host header is the caller's, and a redirect URI built from it is one an attacker chooses. A dedicated hostname is a validated value out of a settings row, so a spoofed Host can only match one this deployment already configured — a Railway deploy hostname in `ALLOWED_HOSTS` falls through to the public site. Each dedicated hostname needs its own redirect URI registered with the provider. `passkeys.expected_origin` relaxes `WEBAUTHN_ORIGIN` by exactly the same rule and for the same reason: pinned to the public site it would refuse the second factor on those hostnames and lock staff out of the site the setting protects. `WEBAUTHN_RP_ID` needs no change — a subdomain of the configured RP ID already passes, which is also what lets one passkey work on both hostnames. The token request authenticates the client with HTTP Basic only. Putting `client_id` in the body as well is a second authentication method; OAuth 2.0 forbids that, and several providers now reject it. ID token `iss` is compared with and without a trailing slash.

Dissolving an organization deletes its provider so the domain can be claimed again.

***


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/identity/audit-and-oidc.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
