# Identity Broker

> Sign the users of many organizations in through one issuer: a broker routes each address to the member organization owning its domain, then issues the tokens.

Every organization is its own issuer. An application whose users belong to several
organizations would otherwise register a client at each one and work out which issuer a
user belongs to. A **broker** is an organization whose clients sign in the users of other
organizations, its **members**: the application connects once, as a plain OpenID Connect
client of the broker, and the user is sent to their own organization from nothing but an
email address.

```mermaid
flowchart LR
    App(["Application<br/>one OIDC client"]) -->|"/authorize"| B["Broker organization<br/>its own issuer, keys, branding"]
    B -->|"email address"| C{"Which member owns<br/>the verified domain?"}
    C -->|"acme.example"| M1["Member acme<br/>own sign-in, MFA, branding"]
    C -->|"globex.example"| M2["Member globex"]
    C -->|"no member"| N["No organization found"]
    M1 -->|"code, redeemed in process"| B
    M2 --> B
    B -->|"tokens: sub, member_org_slug"| App
```

- **The member signs its own users in.** Its sign-in page, password policy, second factor,
  lockout, branding and audit log apply unchanged. The broker never sees a password.
- **The broker issues the tokens.** The application trusts only the broker's issuer. The
  ID token names the member in `member_org`, its stable ID, and `member_org_slug`, so the
  application can tell organizations apart; `sub` is the broker's own account linked to the member's user.
- **Both organizations consent.** A membership is active only once the broker has invited
  the member and the member has accepted. Either side can end it.
- **One hop.** A broker cannot be a member, and a member cannot broker.

## The pieces

| Piece | Where | Who declares it |
|---|---|---|
| The broker | any organization with an active membership | nothing to declare |
| The broker's invitation, and which of its clients it admits | a [`BrokerMembership`](https://www.nauthera.io/docs/v1/broker-membership-resource) with `spec.member` | the broker's identity team |
| The member's acceptance | a `BrokerMembership` with `spec.broker` | the member's identity team |
| The email domains routed to a member | the member's [`Organization`](https://www.nauthera.io/docs/v1/organization-resource) `spec.emailDomains`, proved by a DNS TXT record | the member's platform team, and whoever runs its DNS |

The same can be done through the [admin API](https://www.nauthera.io/docs/v1/user-management#organizations)
(`/v1/admin/orgs/{slug}/broker-memberships`, `/v1/admin/orgs/{slug}/email-domains`). A
membership side or a domain claim declared from Kubernetes cannot be changed there.

```mermaid
flowchart TB
    subgraph broker["Broker: main organization (default)"]
        BNS["Namespace nauthera"]
        BMB["BrokerMembership acme<br/>member: acme<br/>clients: [grafana]"]
        GC["OIDCClient grafana"]
        BNS --- BMB
    end
    subgraph member["Member: acme"]
        ORG["Organization acme<br/>namespaces: [acme]<br/>emailDomains: [acme.example]"]
        MNS["Namespace acme"]
        BMM["BrokerMembership nauthera<br/>broker: default"]
        DNS["DNS: _nauthera-challenge.acme.example TXT"]
        ORG -. "admits" .-> MNS
        MNS --- BMM
        ORG -. "proved by" .-> DNS
    end
    BMB <== "invitation + acceptance = active membership" ==> BMM
    BMB -. "admits only" .-> GC
```

## The membership

Each side is declared from a namespace of its own organization, so a team that cannot
write in the other organization's namespaces cannot consent for it. The server checks
each side against the operator's grant for that side's organization too.

```mermaid
sequenceDiagram
    autonumber
    actor MT as Member's team
    actor BT as Broker's team
    participant OP as Operator
    participant SRV as nauthera-server
    MT->>OP: BrokerMembership broker: default (namespace acme)
    OP->>SRV: accept the invitation from default
    SRV-->>OP: no invitation yet
    OP->>OP: AwaitingBroker, sent again every minute
    BT->>OP: BrokerMembership member: acme (namespace nauthera)
    OP->>SRV: invite acme, admitting grafana
    SRV-->>OP: pending
    OP->>SRV: accept again
    SRV->>SRV: provision the client default-broker at acme, set active
    SRV-->>OP: active
    OP->>OP: both sides Ready, state active
```

```mermaid
stateDiagram-v2
    [*] --> pending: broker's side applied
    [*] --> waiting: member's side applied first
    waiting --> active: broker's side applied
    pending --> active: member's side applied<br/>reserved client provisioned at the member
    active --> active: broker edits spec.clients
    pending --> [*]: broker's side deleted
    waiting --> [*]: member's side deleted
    active --> [*]: either side deleted<br/>reserved client removed
```

`spec.clients` on the broker's side lists the broker's clients the membership admits. A
client it does not admit never routes to that member: a broker can admit its members to
Grafana and keep its Argo CD for its own users.

## Routing by email domain

The broker sends an address to the member that has **verified** its domain, and to no
one else. A member claims a domain in `spec.emailDomains`; the claim reports a TXT record
to publish, and is verified once the record is found.

```mermaid
sequenceDiagram
    autonumber
    actor PT as Member's platform team
    participant OP as Operator
    participant SRV as nauthera-server
    participant DNS as DNS
    PT->>OP: Organization acme, emailDomains: [acme.example]
    OP->>SRV: claim acme.example
    SRV-->>OP: pending, TXT record
    OP->>OP: status.emailDomains shows the record
    PT->>DNS: publish _nauthera-challenge.acme.example TXT
    loop every 5 minutes while pending
        OP->>SRV: verify
        SRV->>DNS: look up the record
    end
    SRV-->>OP: verified
    Note over SRV,DNS: re-checked every 24 hours,<br/>a record that disappears sets it back to pending
```

```sh
kubectl get organization acme -o jsonpath='{.status.emailDomains}'
```

```json
[{"domain":"acme.example","state":"Pending",
  "record":{"name":"_nauthera-challenge.acme.example","type":"TXT",
            "value":"nauthera-domain-verification=…"}}]
```

- A domain is verified for **one organization at most**. A claim on a domain another
  organization verified is reported `Refused`.
- A domain verified by an organization that is not a member of this broker is answered
  as an unknown one, so the broker never reveals who owns a domain outside its members.
- The record can live in Git: with external-dns reading `DNSEndpoint` resources (its
  `crd` source, and `TXT` among its managed record types), a `DNSEndpoint` with the
  claim's token publishes it. A DNS resolver may keep a "not found" answer from before
  the record existed for up to the zone's negative-caching time.

```yaml
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
  name: acme-example-email-domain
  namespace: acme
spec:
  endpoints:
    - dnsName: _nauthera-challenge.acme.example
      recordType: TXT
      recordTTL: 300
      targets: ["nauthera-domain-verification=…"]
```

## A sign-in through the broker

```mermaid
sequenceDiagram
    autonumber
    actor U as User x@acme.example
    participant App as Application (broker client)
    participant B as Broker
    participant M as Member acme
    U->>App: open the application
    App->>B: /authorize
    B->>U: ask for an email address
    U->>B: x@acme.example
    B->>B: acme.example verified by acme,<br/>active membership admits this client
    B->>M: /authorize with the reserved client,<br/>login_hint, fresh state, nonce, PKCE
    M->>U: acme's own sign-in, factors and branding
    U->>M: credentials, second factor
    M->>B: code, redeemed in process
    B->>B: verify the member's ID token,<br/>link the broker's account, start a session
    B->>App: code
    App->>B: /token
    B-->>App: tokens: sub, member_org, member_org_slug
```

The screens are on [Broker sign-in](https://www.nauthera.io/docs/v1/sign-in-flows/broker).

## Mapping members in the application

The application reads `member_org_slug` from the ID token or userinfo. Grafana, for
example, maps each member to a Grafana organization of its own with one provider:

```ini
[auth.generic_oauth]
client_id = grafana
org_attribute_path = member_org_slug && [member_org_slug, email] || ['nauthera', email]
org_mapping = nauthera:1:Viewer acme:2:Viewer globex:3:Viewer admin@acme.example:2:Admin
```

The broker's own users carry no `member_org_slug`, and the expression gives them
`nauthera` instead.

## Limits today

- **Only members are routed.** An address is sent to a member that verified its domain.
  An address of the broker's own organization, or at a domain no member verified, ends at
  "No organization found"; there is no fallback to the broker's own sign-in. To sign the
  broker's own staff in through it, give them an organization of their own that is a
  member, as for any other.
- **Domains decide.** Choosing among several organizations for one address, after a
  one-time code proves control of it, is planned, not built.

## Related

- [BrokerMembership](https://www.nauthera.io/docs/v1/broker-membership-resource): the resource reference
- [Organization](https://www.nauthera.io/docs/v1/organization-resource): `spec.namespaces` and `spec.emailDomains`
- [Broker sign-in](https://www.nauthera.io/docs/v1/sign-in-flows/broker): the screens
- [Operator](https://www.nauthera.io/docs/v1/operator#who-may-change-what): the grant and the team roles
- [Organizations](https://www.nauthera.io/docs/v1/organizations): tenants and their issuers
