# Hosted authentication and token lifecycle
The opt-in [first-party mobile sign-in policy](first-party-mobile-login.md) permits a server-authenticated official mobile client to return directly with its current account/channel scope. It supersedes interactive consent only for that explicitly configured handoff; ordinary OAuth keeps the consent flow documented below.
Repository links below require access to the private publ-bridge repository. The fixed AWS guide is an operator configuration reference, not a deployment approval. Internal review notes are not part of this package or public Reference.
## Unified resource decision (2026-10-14)
New connections use `/mcp`, OAuth scope `publ:mcp`, and an explicit stored approval `{kind: "unified", accountAllowed, channelCodes}`. The consent form starts unchecked and validates every submitted target against the current viewer. Tokens, refresh rotation, revocation and audience checks retain the existing lifecycle. Current seller and approved-channel entitlement is rechecked before dispatch; losing any approved channel currently fails the grant closed and requires a new authorization with the remaining access. Newly created channels never expand a grant automatically.
`discover` and `execute` are the only public tools on this resource. The dispatcher builds the existing scope-restricted business server internally, uses its SDK input/output validation, and stores jobs under a deterministic target namespace inside the grant. There is no mutable selected-channel state. The client dependency used for this in-process SDK dispatch is included in production dependencies; no stdio subprocess or external MCP request is involved.
The earlier per-resource account/channel model below describes retained compatibility endpoints. Those grants remain audience-bound and are not migrated automatically. See the current [connection guide](connection-guide.md). Production deployment and named client/real-account verification remain separate work.
Implementation branch: Status: packaged adapter, PostgreSQL stores, OAuth and six-language controls implemented.
## One publ login, two authentication boundaries
The user confirmed an OAuth connection experience for MCP clients. This does not require publ itself to support OAuth or introduce a second user account.
1. An MCP client discovers our resource and authorization-server metadata and starts an authorization-code request with PKCE S256.
2. Our service displays its existing publ-account login screen. `PublLogin` calls the existing publ `POST /seller/api/v2/sign-in`, then checks the issued token with `GET /seller/api/v1/me`. publ remains responsible for its password, verification, lockout and suspension rules; Bridge does not reproduce or bypass them. The password exists only for that request; no password or password hash is stored in Bridge.
3. A signed-in user reviews the requesting application, its callback address and the exact account or channel resource, then allows or denies access. A valid existing Bridge browser session skips password entry.
4. Our OAuth server issues an authorization code bound to the client, redirect URI, resource, subject, connection and PKCE challenge. The client redeems it for **MCP** access and refresh tokens.
5. The client sends an MCP access token to our service. The server checks the grant and current entitlement, then uses the separately encrypted **publ** credential for the downstream request.
An existing login in the separate publ business website does not automatically establish a Bridge browser session. Sharing that login would require an explicit SSO/session integration. The connection flow itself asks for one publ login, not two account logins. Token refresh normally needs no interaction; revoked/expired upstream credentials can require publ sign-in again.
```mermaid
sequenceDiagram
participant C as MCP client
participant B as User browser
participant M as publ MCP service
participant P as Existing publ authentication/API
C->>M: Discover metadata; authorize with PKCE
M->>B: Existing-account login, if needed
B->>M: publ credentials over HTTPS
M->>P: Existing supported authentication
P-->>M: Verified identity and publ token pairs
M->>M: Encrypt publ pairs; create browser session
M->>B: Show app and requested account/channel access
B->>M: Approve
M-->>C: Short-lived authorization code
C->>M: Redeem code with PKCE verifier
M-->>C: MCP access + rotating refresh tokens
C->>M: MCP request with MCP access token
M->>P: publ API request with stored publ token
```
## Credentials and storage
| Credential | Holder and storage | Lifecycle |
| --- | --- | --- |
| Browser session | Secure, HttpOnly, SameSite=Lax cookie; SHA-256 lookup hash in PostgreSQL | 8 hours; browser logout revokes only this session |
| MCP access token | Client credential store; only SHA-256 hash in our DB | Default 10 minutes, capped by grant expiration; checked on every request |
| MCP refresh token | Client credential store; only SHA-256 hash in our DB | Rotated on every successful use; default 7 days of inactivity, capped by 30-day absolute grant expiration |
| publ seller access/refresh pair | Server only, AES-256-GCM encrypted row | Use actual upstream expiry/refresh behavior; never substitute an MCP token |
| publ channel access/refresh pair | Separate encrypted row per connection and channel | Refresh only the selected channel, or incorporate channel pairs returned by seller refresh |
MCP lifetime values are configurable implementation defaults, not claims about publ's policy. Tokens contain 256 bits of randomness. Browser sessions, MCP access tokens and MCP refresh tokens occupy separate tables/kinds and cannot substitute for one another. There is no default permanent API key.
Reusing an already consumed MCP refresh token revokes the whole related grant, including newly issued access tokens. Unknown tokens and mismatched client/resource requests are rejected. A lost refresh response requires a new OAuth authorization; the server does not return the replacement token from plaintext storage. Consumed refresh hashes must remain until the grant can no longer be used, even after their individual token expiry, so replay remains detectable.
The additive migration is [`deploy/sql/001-hosted-auth.sql`](../../deploy/sql/001-hosted-auth.sql). It creates `bridge_auth` and these tables:
- `seller_accounts`: Bridge subject mapped to immutable publ seller identity and API environment; no copied account passwords or password hashes.
- `publ_connections`: subject/environment binding, version, revocation and refresh journal.
- `publ_credentials`: encrypted seller or channel token pair; unique per connection/kind/channel.
- `browser_sessions`, `mcp_grants`, `mcp_tokens`: separate session, authorization and hashed-token records.
- `oauth_clients`, `oauth_requests`, `oauth_codes`: client callback metadata, browser continuation and single-use PKCE authorization codes. Authorization codes and continuation identifiers are stored as hashes.
AES-GCM additional authenticated data binds ciphertext to subject, connection, API origin, seller/channel kind and channel code. Moving a ciphertext to another owner's or channel's row fails authentication. Keys are supplied as a key ring from Secrets Manager or an approved key provider, never from this DB. The current key writes new ciphertext; retained older keys read older rows. Re-encrypt remaining rows before removing an old key. No secret values belong in source, documentation, logs, tool inputs/results or job bodies.
## publ policy found in the source repository
Source repository: `publ-sw-core`. These are observed client behaviors, not invented fixed token lifetimes:
Bridge follows those separate refresh paths. A returned replacement refresh token is saved; an omitted or null replacement preserves the current token. Seller refresh preserves channel entries absent from the response, matching the observed update behavior; current entitlement checks remain authoritative for removal/suspension. Exact issuer TTLs and revocation conditions cannot be inferred from this frontend repository alone.
Hosted preflight uses JWT `exp` only as a scheduling hint, never as signature/identity verification. `publRefreshThresholdSeconds` is configurable; the Bridge default is 60 seconds and must be reconciled with the deployed publ setting. Unknown/opaque expiry is not fabricated. A 401 is surfaced to recovery rather than automatically replaying business mutations.
Each hosted connection uses a PostgreSQL session advisory lock across processes and a committed refresh marker before contacting publ. A new pair is verified through the maintained upstream identity/entitlement integration and persisted atomically. Timeout, malformed response, failed verification or failed persistence leaves the connection requiring reauthentication; a crash can leave `refreshing` for operator inspection. Such a row is never automatically unlocked by a timeout and retried with the old refresh token. Advisory-lock loss alone cannot erase the durable marker. Use direct PostgreSQL connections or a session-pooling proxy; transaction-pooling proxies do not preserve these locks.
The existing `HttpPublPort` accepts a `CredentialStore` instead of requiring a filesystem path. Existing local callers retain private file storage; hosted callers use `PostgresCredentialStore`. Business workflows and publ HTTP endpoints are reused. Parallel preflight refreshes within a request are sequenced. No business write is replayed to hide an uncertain outcome.
## Implemented OAuth HTTP surface
| Route | Behavior |
| --- | --- |
| `/.well-known/oauth-authorization-server` | Same-origin issuer, authorization/token/revocation metadata, PKCE S256, supported scopes |
| Existing protected-resource metadata routes | Advertise that issuer for the exact account/channel resource |
| `/oauth/register` | Optional dynamic registration for public clients; enabled explicitly with a shared rate limiter |
| `/oauth/authorize` | Exact registered callback, client, resource and scope validation; creates a bounded browser continuation |
| `/oauth/continue` | Existing session or one publ login, then six-language consent |
| `/oauth/consent` | Origin and session/continuation-bound CSRF checks; allow or deny; authorization code expires after 90 seconds |
| `/oauth/token` | Authorization code with PKCE or rotating refresh grant; resource remains bound (a missing `resource` uses the one the code or refresh grant is bound to; a trailing `/` on the same resource is the same resource; any other host or path answers `invalid_target`); no password, implicit or client-credentials grant. A refused request is logged (`oauth_token_failure`) and recorded in System Admin with the check that refused it |
| `/oauth/revoke` | Possession-based public-client token revocation, scoped to the registered client |
The maintained `@node-oauth/oauth2-server` library validates code exchange and PKCE; our persistence handles atomic code consumption, grant creation, replay revocation and token rotation. Public callbacks must be exact HTTPS URLs or literal HTTP loopback IP URLs. Client-supplied names are labeled as such. No client metadata URLs are fetched, so dynamic registration is not a server-side URL-fetch facility.
The initial integration supports public OAuth clients using `token_endpoint_auth_method: none`. Client ID Metadata Documents, confidential-client authentication, device authorization and every client/version combination are not implemented or certified. Dynamic registration supports legacy/current clients that use that mechanism; it is not proof of compatibility with every MCP client. A client needs remote HTTP MCP and the advertised OAuth flow. The LLM model itself does not manage these tokens. See the [MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) and [OAuth security BCP](https://www.rfc-editor.org/rfc/rfc9700.html).
## Packaged deployment adapter
`src/hosted/auth/adapter.ts` exports `createHostedServices(settings)` and composes the console, OAuth, publ HTTP port and shared PostgreSQL storage in the same application image. Set `PUBL_HOSTED_ADAPTER_MODULE=/app/dist/hosted/auth/adapter.js`. A separate adapter implementation is no longer required for this seller-login path. The lower-level factory remains available for a maintained alternative integration.
The implemented upstream contracts come from `publ-sw-core` session/user sagas, session types, JWT helpers and API endpoint definitions:
- Email/password go only to `POST /seller/api/v2/sign-in`. Its token pairs are schema-checked; an email-verification-only response requires completing verification at publ.biz.
- `GET /seller/api/v1/me` must accept the issued seller token and return the same seller ID and login email. This adapter supports the existing seller OWNER login. Separate channel-manager login keys and DELEGATEE/CREATOR sign-in are not implemented by this adapter.
- Browser access and MCP authorization reread `/me` and bind the result to the stored publ seller ID. For a channel, the selected channel token must also read `/seller/api/v1/channels/{code}` with the matching channel code and seller. Workflows and publ still enforce individual action permissions.
- Dashboard candidates page both RUNNING and NO_INTEREST channel lists and require a saved channel credential. A candidate is not proof of all channel permissions. The current dashboard bounds each filter at 5,000 records and fails closed for larger inventories instead of silently claiming a complete list.
- Changed seller tokens are checked against `/me`; changed channel tokens are checked against their channel resource before encrypted persistence. Existing publ refresh routes and omitted-refresh-token preservation remain as described above.
Set `PUBL_AUTHORIZATION_ISSUER` equal to `PUBL_PUBLIC_ORIGIN`. No developer credential file or implicit dev API environment is loaded.
### Durable work and coordination
`PostgresJobStore` replaces local job files in hosted contexts. `JobStore.child()` preserves the selected backend for all workflow namespaces; stdio retains its existing file store. Jobs bind to an OAuth grant, and every access checks its subject, connection, client and exact resource. Refreshing MCP tokens retains the grant and its jobs. A new authorization has a separate namespace; it cannot adopt another grant's work.
A session advisory lock coordinates replicas. A committed operation marker exists before a workflow callback starts, and each recovery-state write commits before/after the corresponding publ operation. A crash or lost DB connection retains the marker and blocks automatic execution; status can still read the saved body. An operator must inspect upstream effects and the saved phase before clearing an abandoned marker. Never clear markers on a timer or replay an uncertain write with a new task ID. Completed callbacks release their markers; existing workflow phases continue to determine safe resume behavior. Direct PostgreSQL connections or session pooling are required; transaction pooling is unsupported.
`002-hosted-runtime.sql` adds `workflow_jobs` and `rate_limits` after `001-hosted-auth.sql`. The image packages both migrations but never applies them at startup. Use a separate migration identity and give the runtime only schema usage and required table DML. Job bodies may contain business content and bounded image inputs from admitted workflows; protect DB/backups, set retention with the operations owner, and keep auth credentials out of those bodies. Hosted file-asset staging remains unavailable.
The shared rate limiter enforces per-minute global and transport-peer limits on login and OAuth, plus ten login attempts per normalized identifier. It stores keyed hashes, not email/IP/password values. `PUBL_TRUSTED_PROXY=alb` requires ALB-only task ingress, X-Forwarded-For append mode and client-port preservation disabled. It uses the final ALB-appended address and rejects missing/invalid metadata. In `none` mode, it uses the socket peer and ignores forwarded headers. Infrastructure must add suitable ingress limits. Expired rate counters need operator-owned pruning.
### User and operator lifecycle controls
The dashboard lists active client grants for the signed-in seller in pages of 20, labels client-supplied names as unverified, and provides CSRF-protected revoke buttons in all six UI languages. Revoking one grant invalidates its access/refresh tokens while preserving unrelated grants. A separate disconnect action deletes publ credentials saved by the current login, revokes its MCP grants/browser sessions, and signs out. Other login connections remain separate. Browser logout alone retains MCP grants. Local disconnection does not claim upstream token revocation or cancel a publ operation already accepted in flight.
Prune expired browser continuations/sessions/codes and expired grants through an operator-owned retention policy; preserve replay records for every live grant and decide job retention before deleting parent grants. Retain older encryption keys until their credential rows have been re-encrypted. Closing an account should disable its subject and disconnect its connections. Disable SQL parameter/body logging and back up encrypted rows with recoverable, separately protected key references.
## MCP + CLI proposal
The user's two-entry CLI idea remains a proposed public interface; no binary, installer or `/cli/*` API is shipped. A development probe now demonstrates a separate CLI process calling the same HTTP MCP tool without stdio or fetching the whole catalog. HTTP endpoint count and the number/size of tool schemas loaded into an agent's context are different quantities. A server can expose many tools through a single MCP transport URL.
A compact search/describe/execute MCP surface and an optional thin CLI can share the same workflow registry and dispatcher. Both must preserve scope, idempotency, confirmation, result/navigation and recovery contracts. Discover schemas on demand rather than embedding a complete catalog into the wrapper's description. A CLI can authenticate through the same OAuth code/PKCE flow and invoke either standard HTTP MCP `tools/call` or a separately defined HTTP facade. `/cli/*` by itself is not an MCP standard and is unnecessary if HTTP MCP already satisfies the CLI.
This avoids duplicating business logic and does not require stdio for the CLI. It still requires client transport, secure token storage, download integrity, packaging and update support. Shell-less clients cannot run a downloaded CLI, so a CLI-only design would reduce compatibility. See [MCP tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools).
## Verified account email metadata
Apply `deploy/sql/007-seller-account-email.sql` after 001 for the nullable
`publ_email` label and its lookup index. The verified publ self response supplies
this value at login and during existing same-seller authorization/viewer checks.
This metadata supports separate System Admin usage views, never authentication,
authorization, grant ownership or grouping identity. Legacy rows remain null
until verified; no password or additional upstream token is retained.
## /mcp browser origins
`/mcp` refuses a request whose `Origin` header is not the public origin or one of `PUBL_MCP_ALLOWED_ORIGINS` with 403
`forbidden_origin`, including the preflight. The DEV launcher allows `https://claude.ai` and `https://claude.com`. A refused
origin is logged as `mcp_origin_rejected` with the origin, method, path and request id, and recorded in System Admin. The
preflight allows `Authorization`, `Content-Type`, `Accept`, `MCP-Protocol-Version`, `MCP-Session-Id` and `Last-Event-ID`;
responses expose `WWW-Authenticate`, `MCP-Protocol-Version` and `MCP-Session-Id`. A request without `Origin` is unaffected,
and a request without a valid token still answers 401 `invalid_token` with the resource metadata challenge. Every response
carries `x-request-id`, which the access log line (`type: access`) and the failure records use.
## Channel entitlement checks
- token issuance and refresh: the seller identity and the paged channel directory, the source the consent screen uses.
A channel that left the directory does not refuse the whole connection; a call on it is refused by its own check.
- the start of each MCP request: the grant and the seller identity (a channel grant: its own channel).
- every tool call and every port call inside it: the seller identity and the one channel the call acts on (its detail
read and its stored token's seller). Nothing is cached, so a write still stops when access is lost after its read.
A refused call answers `AUTH_REQUIRED` with the reason (for example `channel_not_approved`) and `outcome: not_sent`; the
connection stays usable for its other channels. A refused request start answers 401 and is recorded in System Admin as
`mcp_authenticate` with the reason, client, grant kind and channel count, and the requested and bound resources. A
request without a token, the normal first step of a connection, is not recorded. Each issuance logs
`mcp_grant_entitlement` with its time in milliseconds and channel count.