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
19 changes: 6 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PoliNetwork Auth prototype

A standalone TanStack Start and Better Auth identity provider. The backend remains the active authentication provider for all existing PoliNetwork applications. This repository does not change backend authentication, migrate production data, or grant Telegram moderation access.
A standalone TanStack Start and Better Auth identity provider. As of RFC v3 Phase 6 (October 2026), every PoliNetwork service signs people in through this IdP; no applications still use the backend's removed login (see [Backend login removal](#backend-login-removal)). This repository does not migrate production data or grant Telegram moderation access.

## Run locally

Expand Down Expand Up @@ -337,7 +337,7 @@ Master Admin registers the four Phase 1 clients on `/applications/new`, each wit

During a sign-in the login page names the requesting application. The consent page at `/consent` shows the app, who is signed in (with a switch-account option), each requested scope in plain language, any requested profile claims, and where the browser will be sent; it supports allow and deny and explains expired or disabled requests.

Future consumers should use authorization code with PKCE, validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group.
New services follow [docs/service-integration-guide.md](docs/service-integration-guide.md). Future clients that sign people in should use authorization code with PKCE; service clients use client credentials, as the guide describes. All of them validate token signatures, issuer, audience, and expiration, and request `polinetwork:identity` only when needed. APIs must check their own permissions. An ID token or a linked Telegram ID alone is not permission to moderate a group.

### People roles

Expand Down Expand Up @@ -436,20 +436,13 @@ The server follows the MCP authorization spec. `/.well-known/oauth-protected-res

Tokens are EdDSA JWTs addressed to `/api/mcp`, valid five minutes. Every request checks the token, that the agent's client still exists with your consent, and that you still hold `idp:mcp`, so taking the permission away or disconnecting the agent from your account page ends access on the next request. People without `idp:mcp` are stopped on the consent page. Registrations nobody approves within a day are deleted, and so are agents nobody has used for 60 days; each use is recorded on the agent's consent, at most hourly. Registration is rate-limited to five a minute per address.

## Migration work before cutover
## Backend login removal

Inspection found backend auth in `../backend/src/auth/index.ts`, with custom email OTP, passkeys, shared subdomain cookies, and custom Telegram linking. `../admin` reads `user.telegramId` and calls backend Telegram permission routes. `../group-bot` also uses those backend assignments.
The backend's own login (better-auth with email OTP, passkeys and Telegram link codes) was removed in RFC v3 Phase 6: every PoliNetwork service signs people in through this IdP. Browsers may still hold the backend's old `better-auth` cookies on `.polinetwork.org` until they expire; this service's `pn-identity` cookie prefix (`src/auth/cookies.ts`) keeps them from shadowing its session.
Comment thread
toto04 marked this conversation as resolved.
Resolved

The backend scopes its Better Auth cookies to `.polinetwork.org` with the default `better-auth` prefix, so browsers send them to this service too. This service uses its own `pn-identity` cookie prefix (`src/auth/cookies.ts`) so those cookies cannot shadow its session. Keep the prefixes different while both run.
To connect a new service, follow [docs/service-integration-guide.md](docs/service-integration-guide.md).

Keep these integrations running while testing this service. A later migration needs:

1. Back up and inventory backend users, accounts, passkeys, Telegram links, roles, and foreign keys. Preserve user IDs or define a reviewed mapping. This prototype's schema is not a replacement migration for the backend's prefixed tables.
2. Implement a bridge that proves ownership of the existing backend session before connecting an existing user here. Existing backend passkeys are not imported; users register new passkeys in this service. The prototype does not preserve email OTP login. Never import Telegram usernames as ownership proof or manufacture OIDC accounts from old links.
3. Require official Telegram re-verification and reconcile the verified numeric ID with existing moderation assignments. Review conflicts instead of merging automatically.
4. Confirm the PN membership rule and choose how often students must reverify their Polimi email.
5. Test a downstream application in staging, including authorization code, consent, refresh, logout, account conflicts, and revoked permissions. The current shared-cookie clients cannot be pointed at this issuer without integration changes.
6. Schedule a separate cutover and rollback plan. Retain the backend login until those checks pass.
Still open: confirm the PN membership rule and how often students must reverify their Polimi email.

## Validation

Expand Down
2 changes: 2 additions & 0 deletions docs/idp-telegram-integration-rfc-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,8 @@ The principle: **each caller moves independently, and each step can be rolled ba

## 14. Making this easy to extend

> **Implemented.** The practical guide is [service-integration-guide.md](service-integration-guide.md). It records where the implementation differs from this section: `auth-kit` option names, projections without prefixes, and IdP code changes for a new resource server.

### 14.1 Shared library: `@polinetwork/auth-kit`

| Function | What it does |
Expand Down
153 changes: 153 additions & 0 deletions docs/service-integration-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Integrating a service with the PoliNetwork IdP

**Status:** current as of RFC v3 Phase 6 (October 2026).
**Scope:** every PoliNetwork service that signs people in, calls the backend, or authorizes requests with IdP permissions. This guide replaces RFC v3 §14 where they differ. The differences are listed at the end.

The IdP is the only source of identity and permissions. A service never stores its own roles and never treats a Telegram ID, or the mere presence of an ID token, as permission. It checks **permissions**, never role names: a Level 1 service authorizes from the validated `polinetwork_permissions` claim (RFC v3 §4, decisions 1 and 4).

## 1. Choose the integration level

| Level | Example | What the service does | Revocation lag |
| --------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **1. Sign people in** | polinet.cc, a new web app | Registers an OIDC client, requests `polinetwork:identity` and reads `polinetwork_permissions` from the ID token or UserInfo. | **As long as the app's session lasts**, unless the app re-reads UserInfo or refreshes. Re-check at least every 5 minutes, or keep sessions short. |
| **2. Call the backend** | website, Telegram bot, admin dashboard | Gets an access token for the backend resource: client credentials for itself, or authorization code for a signed-in person. Sends it as `Authorization: Bearer`. | Set by the backend: about 1 s after an access change (§5.6). |
| **3. Run a privileged API** | backend | Verifies access tokens and authorizes each call against a local copy of the access snapshot. | About 1 s (§5.6). |

Most new services are level 1, or level 1 + 2. Level 3 needs changes to the IdP's code today (§5).

## 2. Level 1: sign people in

1. **Register the application** at `https://auth.polinetwork.org/applications/new`. You need `idp:applications:write`.
- **Usage:** "Signs people in".
- **Authentication:**
- **Public (PKCE only)** for single-page and native apps.
- **Client secret** or **private key** (`private_key_jwt`) for a server.
- **Redirect URIs:** "Public https URLs only" for web apps; "Also localhost and app schemes" for native apps and local development.
- **Scopes:** `openid`, plus `profile`, `email` and `offline_access` as needed, and `polinetwork:identity` only if the app needs permissions or the Telegram ID.
2. **Use authorization code with PKCE.** Discovery is at `https://auth.polinetwork.org/api/auth/.well-known/openid-configuration`, and the issuer is `https://auth.polinetwork.org/api/auth`.
3. **Validate every token:** signature, `iss`, `aud`, `exp`. Use an OIDC library, not hand-written checks.
4. **Authorize with permissions.** `polinetwork:identity` adds:
- **`polinetwork_permissions`:** space-separated, empty when the person has none.
- **`polinetwork_telegram_id`:** empty when no Telegram account is linked.
- **The URL-named object claim**, described in the [README](../README.md#oidc-clients).

Check for a specific permission, e.g. `rooms:book`. Roles are only labels for people; the claims also carry `roles` and `groups`, but services must not authorize on them.

5. **Keep permissions fresh.** The claims are a snapshot taken at sign-in. Re-read UserInfo or refresh at least every 5 minutes, or keep the session short. polinet.cc today keeps them for up to 7 days.

If the app needs new permissions, create them as in §4.

## 3. Level 2: call the backend

The backend accepts only IdP access tokens addressed to its resource (`OAUTH_BACKEND_RESOURCE_URI`). Every procedure declares, per kind of caller, the scope it needs and, for people, the permission they must hold. The backend's README, section "Authentication", has the full contract.

### 3.1 As the service itself (client credentials)

Used by the website (`backend:public:read`) and the Telegram bot (`backend:tg:*`).

1. **Register the application** (Master Admin only: API access is privileged).
- **Usage:** "Calls APIs as itself".
- **Authentication:** private key. Paste the **public** Ed25519 or P-256 JWKS.
- **Scopes for the app itself:** only the backend scopes it needs. Resource links follow the scopes when you save.

| Scope | For |
| --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `backend:public:read` | public data (groups, website content) |
| `backend:tg:read`, `backend:tg:ingest`, `backend:tg:groups:sync`, `backend:tg:audit`, `backend:tg:events` | the Telegram bot |
| `backend:tg:act-as` | acting for a linked Telegram user with `X-PN-Actor: telegram:<id>`; the bot only |

2. **Keep the private key in Key Vault**, as its own secret, mounted only into this service through its `SecretProviderClass` / `ExternalSecret` in `polinetwork-cd`. Never paste a private key into the IdP.
3. **Get tokens with `auth-kit`:**

```ts
import { idpEndpoints, serviceTokenSource } from "@polinetwork/auth-kit";

const endpoints = idpEndpoints({
publicUrl: env.IDP_PUBLIC_URL,
internalUrl: env.IDP_INTERNAL_URL,
});
const tokens = serviceTokenSource({
clientId: env.OAUTH_MY_SERVICE_CLIENT_ID,
privateKey: JSON.parse(env.OAUTH_MY_SERVICE_PRIVATE_JWK), // JWK with kid, alg (EdDSA or ES256), d
tokenUrl: endpoints.tokenUrl,
tokenAudience: endpoints.tokenAudience,
resource: env.OAUTH_BACKEND_RESOURCE_URI,
scopes: ["backend:public:read"],
});

// On each request:
headers.Authorization = `Bearer ${await tokens.getToken()}`;
```

- **Caching and renewal:** tokens are cached and renewed at 50% of their lifetime, and concurrent callers share one request.
- **On a `401` from the backend:** call `tokens.invalidate()` and retry once. A 401 is decided before the procedure runs, so retrying a mutation is safe.
- **Reference:** `web/src/lib/backend.ts`.

4. **Call the backend in-cluster**, at `http://backend-service.backend.svc.cluster.local:3000`. The backend has no public route since Phase 6, and its NetworkPolicy admits only the callers listed in `polinetwork-cd/apps/backend/network-policy.yaml`. Add the new service there.
Comment thread
toto04 marked this conversation as resolved.
Resolved

### 3.2 For a signed-in person (authorization code)

Used by the admin dashboard.

- **Registration:** register the app as "Signs people in" with `openid profile email offline_access`, and grant `backend:admin` for signed-in people.
- **Token request:** request the access token with `resource=<OAUTH_BACKEND_RESOURCE_URI>`, so it is a JWT addressed to the backend.
- **Server-side only:** keep tokens on the server (backend-for-frontend) and call the backend from server functions.
- **Reference:** `admin/src/server/auth-config.ts`.

## 4. Permissions and roles

1. **Create the permissions** at `/access/permissions/new`, e.g. `rooms:book` and `rooms:manage`. Keys match `^[a-z0-9]([a-z0-9._:-]*[a-z0-9])?$`; prefix them with the service's name.
2. **Attach them to roles** at `/access/roles/$roleId`. Which roles get which permissions is runtime configuration chosen by administrators (decision 11), not code.
3. **Master Admin holds every permission**, including future ones (decision 10).
4. **Removing or renaming a permission** silently denies whoever relied on it. Change the service and the roles together.

## 5. Level 3: a new resource server

Today the IdP serves one external resource server, the backend, besides its own MCP server (`/api/mcp`). Adding another service that verifies tokens and uses the access snapshot needs **a code change to the IdP**, reviewed like any change to authorization rules:

| What | Where in `auth` |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| A resource (identifier, TTLs, allowed scopes) | `serviceResources` in `src/auth/index.ts` and a new `OAUTH_*_RESOURCE_URI` setting in `src/env.ts` |
| The scopes | `apiScopes` and `oidcResources` in `src/auth/oidc-clients.ts`; `isApiScope` decides which scopes are privileged |
| A projection (which permissions and fields the service sees) | `ACCESS_PROJECTIONS` in `src/auth/access-projections.ts` and the projection name hard-coded in `src/auth/access-snapshot.ts` |
| Access-changed events | `src/auth/access-dispatcher.ts` sends only to `OAUTH_BACKEND_EVENTS_URL` |

On the service side, follow the backend (`backend/src/idp/`):

| `auth-kit` export | Use |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `createKeyStore({ jwksUrl, persistence })` | last-known-good JWKS; call `start()` |
| `verifyAccessToken(token, { issuer, audience, keyStore })` | returns `{ kind, clientId, sub, scopes, claims }` |
| `resolveActor(verified, { actorHeader, actAsScope, snapshot })` | the request's `Actor`: `service`, `telegram` or `user` |
| `accessSnapshot({ url, tokens, projection, persistence })` | polled local copy; `has(actor, permission)` denies for services, unknown subjects and a stale snapshot |
| `createAccessEventHandler({ … })` | Fetch handler for the IdP's events, mounted on an internal path |

Enforcement is deny-by-default: every operation declares its scope and permission per kind of caller, as in `backend/src/trpc.ts` (`policy()`) and `backend/src/idp/policies.ts`.

## 6. Keys and rotation

- **Signing keys:** the IdP's JWT keys rotate every 7 days, and their public keys overlap for 30 days. `auth-kit`'s key store follows the rotation.
- **Rotating a client's private key:**
1. add the new public key to the application's JWKS;
2. deploy the matching private key to the service;
3. after the overlap, remove the old public key.

## 7. Checklist

- [ ] Level chosen, and the smallest set of scopes listed.
- [ ] Permissions created and attached to roles (§4).
- [ ] Application registered.
- [ ] Private key, if any, stored in Key Vault and mounted only into this service.
- [ ] Tokens validated with a library or `auth-kit`.
- [ ] Authorization checks permissions, never roles.
- [ ] Level 2: service added to the backend NetworkPolicy in `polinetwork-cd`.
- [ ] Level 3: IdP change reviewed (§5).

## Differences from RFC v3 §14

- `serviceTokenSource` takes `privateKey`, `tokenUrl` and `tokenAudience`. `idpEndpoints()` computes the last two.
- `createKeyStore` takes `jwksUrl` and `persistence`, and must be started.
- `verifyAccessToken` also needs `issuer` and returns the verified token. The actor comes from `resolveActor`.
- The event receiver is `createAccessEventHandler`, separate from `accessSnapshot`.
- Projections have no `prefixes`. The single `backend` projection carries every permission except the IdP's own `idp:*`.
- A new resource server needs IdP code changes (§5), not only configuration.
Loading