> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth2 Provider — Sign in with Softcrum

> After this ships, a third-party app can ask a tenant "may I read your contacts and award points?" and get a scoped token — and a tenant's own site can offer "Sign in with Softcrum" so their customers reuse the identity they already have.

## Context

OAuth in this platform runs in **both directions**, and the tenant picks which they want:

* **Outbound (this spec).** Softcrum is the provider. A third-party app acts on a tenant's behalf
  with scoped consent, and a tenant's own site can offer "Sign in with Softcrum" so a member reuses
  the identity they already have with that tenant.
* **Inbound (FS-IDN-0008).** Softcrum is the client. A tenant's staff sign in through the
  organization's own identity provider, or through Google, Microsoft or GitHub.

Neither is the default; both are configuration. A company with its own IdP routes its staff inward,
and the same company may still expose "Sign in with Softcrum" on its storefront for its customers.

Without the outbound half, every third party that wants to build on Softcrum asks the tenant for an
API key with everything on it, and the tenant either over-grants or refuses. Both cost us the
integration.

DEC-D5 makes this cheaper than it usually is: **OAuth scopes are bundles of the same permissions the
platform already has**. There is no second authorization vocabulary to design, document and keep in
sync — the consent screen renders real permissions, and a token carries real role assignments. An
invented scope language is where OAuth implementations become inconsistent with the product they
guard.

The distinction from an API key (FS-IDN-0006) is who grants what: a key is issued by the tenant to
themselves; an OAuth client is granted by the tenant **to somebody else**, which is why it needs
consent, revocation visible to the granter, and a refresh path.

## Scope *(normative)*

* `identity.oauth_clients`: registered third-party integrations.
* **Authorization code with PKCE** for apps acting on a user's behalf.
* **Client credentials** for server-to-server integrations acting as themselves.
* A consent screen rendering the actual permissions requested.
* **OIDC on top of OAuth2**: `id_token`, `.well-known/openid-configuration`, a JWKS endpoint and
  `userinfo`, so "Sign in with Softcrum" works with any standard client library.
* **Both realms are addressable.** A client may authenticate an organizational user (tenant staff
  signing into a partner tool) or a member (a tenant's customer signing into that tenant's own
  site). The realms stay separate; a client declares which one it targets and can never reach the
  other.
* Access and refresh tokens with rotation.
* Revocation by the tenant, per client, visible in a list of connected apps.
* `GET /oauth/userinfo` and token introspection.

## Non-scope *(normative)*

* **Being a general-purpose login for unrelated products.** "Sign in with Softcrum" is offered so a
  tenant's own surfaces — their storefront, their portal, a partner tool they authorised — can reuse
  an identity that already belongs to that tenant. It is not a public identity network, and a client
  is always scoped to the organization that authorised it.
* Implicit and password grants. Both are deprecated and neither will be offered — supporting a grant
  we know is unsafe to please one integrator is how a provider ages badly.
* A public app marketplace. That is a product surface of its own; this is its precondition.
* Dynamic client registration in F1b. Clients are registered by us, reviewed, until volume justifies
  automating it.

## Behaviour *(normative)*

1. **Authorization code with PKCE is mandatory** for user-facing flows, for every client including
   confidential ones. Implicit and password grants are not implemented at all.
2. Scopes are **bundles of platform permissions**, not a separate vocabulary. A scope requesting
   `loyalty.redemptions.create` grants exactly that permission and nothing adjacent.
3. The consent screen shows the permissions in **plain language**, names the client, and states what
   the client can do — not an opaque scope string the tenant has to decode.
4. A granted authorization creates role assignments for the client as a subject (FS-IDN-0002), so the
   check at request time is identical to a user's.
5. Authorization codes are single-use and expire in **60 seconds**. Redirect URIs match exactly —
   no prefix or wildcard matching, which is where redirect-based attacks live.
6. Access tokens are short-lived (**1 hour**); refresh tokens rotate on every use, and a reused
   refresh token **revokes the whole authorization** and notifies the tenant. Reuse means the token
   was stolen.
7. A tenant sees connected apps in a list and can revoke any of them. Revocation is immediate across
   access and refresh tokens.
8. A client belongs to a developer and is granted **per organization**. One client authorized by
   three tenants holds three independent authorizations, revocable independently.
   8b. A client declares the realm it targets, `user` or `member`, at registration and **can never
   reach the other**. A member-targeting client authenticates members of the granting organization
   only, and the `sub` claim it receives is scoped to that organization — consistent with member
   identity being per tenant (`standards/data.md` §2b). Two tenants using the same client for the
   same person's email receive two different subjects, and correlating them is impossible by
   design.
9. A client may never request a permission its own registration does not declare, and never a
   permission the granting user does not hold. A user cannot grant more than they have.
10. Every authorization and revocation writes to `core.audit_log` and is visible in the tenant's own
    trail.
11. FORBIDDEN: a token in a log, a URL fragment persisted anywhere, or a client secret in a
    browser-delivered bundle.

## Data *(normative)*

| Table                           | Key invariants                                                                                                                                                                                   |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `identity.oauth_clients`        | `client_id` unique; `secret_hash` (confidential clients only); `name`, `logo_url`, `developer_contact`; `redirect_uris` exact-match array; `allowed_permissions`; `is_confidential`; `is_active` |
| `identity.oauth_authorizations` | unique (`client_id`, `organization_id`); granted permissions; `granted_by` user; `granted_at`, `revoked_at`; refresh token family id                                                             |
| `identity.oauth_codes`          | `code_hash` unique; PKCE challenge; `expires_at` 60 s; `consumed_at`; single-use                                                                                                                 |

## API *(normative)*

| Endpoint                                  | Class      | Auth                              | Budget       |
| ----------------------------------------- | ---------- | --------------------------------- | ------------ |
| `GET /oauth/authorize`                    | Runtime    | user session                      | p95 \<1 s    |
| `POST /oauth/token`                       | Runtime    | client credentials or code + PKCE | p95 \<300 ms |
| `POST /oauth/revoke`                      | Runtime    | client or session                 | p95 \<300 ms |
| `GET /oauth/userinfo`                     | Runtime    | access token                      | p95 \<300 ms |
| `GET /.well-known/openid-configuration`   | Runtime    | public                            | p95 \<300 ms |
| `GET /oauth/jwks`                         | Runtime    | public                            | p95 \<300 ms |
| `GET /v1/identity/connected-apps`         | Management | `identity.oauth.read`             | p95 \<1 s    |
| `DELETE /v1/identity/connected-apps/{id}` | Management | `identity.oauth.revoke`           | p95 \<1 s    |

OAuth endpoints live at the root, outside `/v1/{module}`, because they follow a standard that
clients and libraries expect at conventional paths. It is the one documented exception to DEC-D1.

## Events *(normative)*

`identity.oauth.authorized` and `identity.oauth.revoked`, carrying client and organization.

## Acceptance criteria *(normative)*

1. An authorization code flow without PKCE is rejected, for confidential clients too.
2. A redirect URI differing by one character is rejected — no prefix matching anywhere.
3. An authorization code is single-use and expires at 60 seconds.
4. A reused refresh token revokes the entire authorization and notifies the tenant.
5. A client requesting a permission outside its registration is rejected at the authorize step, with
   the reason shown to the user.
6. A user cannot grant a permission they do not themselves hold.
7. Revoking a connected app invalidates its access and refresh tokens immediately.
8. The same client authorized by two organizations holds two independent authorizations; revoking
   one leaves the other working.
   8b. A member-targeting client cannot authenticate an organizational user, and vice versa.
   8c. The same email as a member of two organizations yields two different `sub` claims, and no
   response allows the two to be correlated.
9. **Negative:** implicit and password grants return `unsupported_grant_type`, and no token appears
   in any log.

## Execution

Single slice, synchronous command. F1b. Better Auth's OAuth provider capability where it fits; any
gap is implemented against the specification rather than by loosening it. The consent screen is a
`frontend/console` route driven by this module's data.

## Open questions

| # | Question                                                                      | Decides | By              |
| - | ----------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Are clients reviewed by us before activation, or self-service from the start? | Daniel  | before approval |
| 2 | Refresh token absolute lifetime — 30 days, or 90 with rotation?               | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
