Skip to main content

authentication

How auth works in agent-native apps. Use when wiring login/signup, configuring auth modes, setting up organizations, protecting routes, or debugging session issues.

소스 정보

저장소
BuilderIO/agent-native
최근 소스 활동
2026년 10월 19일 17:13
감지된 SKILL.md 언어
영어
스타
7,449
포크
681

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
authentication
description
How auth works in agent-native apps. Use when wiring login/signup, configuring auth modes, setting up organizations, protecting routes, or debugging session issues.
scope
dev
metadata
{"internal":true}
# Authentication ## Rule Auth is powered by **Better Auth** with account-first design. Every new user creates an account on first visit. Use `getSession(event)` to authenticate custom routes; actions are auto-protected. Normal app HTML and React Router page-data responses are one impersonal, public-cacheable shell for every visitor. The client decides whether to render private UI or redirect to sign-in. ## Auth Modes | Mode | Behavior | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Development (default)** | Real Better Auth — same flow as production. There is **no auth bypass**. On first run the framework auto-creates a throwaway dev account and signs you in without printing its credentials (disable with `AGENT_NATIVE_DISABLE_AUTO_DEV_ACCOUNT=1`), so you are not stuck at a login wall. `getSession()` returns the signed-in user or `null` — it never falls back to a sentinel identity. | | **Production (default)** | Magic-link-first Better Auth when outbound email is ready, with email/password fallback and social providers (Google, GitHub). Organizations built in. | | **`AUTH_MODE=local`** | **Not** a browser auth bypass, and never returns `local@localhost`. It only affects CLI/agent identity: it lets `pnpm action` / the local agent loop auto-bind to the single real signed-in dev user from the `sessions` table (see `scripts/dev-session.ts`). Browser login is unchanged. | | **`AUTH_SKIP_EMAIL_VERIFICATION=1`** | QA/preview escape hatch for password-fallback accounts. Signup skips email verification and does not send the signup verification email. Local dev/test skips verification by default; set `AUTH_SKIP_EMAIL_VERIFICATION=0` only when testing verification itself. It does not change magic-link delivery. Give test accounts a test-identity address, such as `an-e2e-probe-you@example.com` or one containing `+qa-test-bot-` (the rule is `isTestIdentityEmail` in `packages/core/src/shared/qa-test-email.ts`), so they stay out of metrics. Test identities still get sign-in, verification, password-reset, and org-invitation mail, but no share notices or other app email. | | **`auth.requireEmailVerification`** | Declares the password-signup verification policy for any environment, production included, via `defineAppConfig({ auth: { requireEmailVerification: false } })` or `AUTH_REQUIRE_EMAIL_VERIFICATION=0`. A declared value outranks `AUTH_SKIP_EMAIL_VERIFICATION` and the per-environment default. `false` accepts an unverified address as a login credential; `true` with no email provider disables password signup instead of stranding accounts on a verification nobody can deliver. | | **`AUTH_MAGIC_LINK=0`** | Force the email/password fallback even when outbound email is ready. | | **`AUTH_DISABLED=true`** | Skip login/signup entirely — every request runs as `dev@local.test`. For local dev, cloud previews, and internal demos only; not for production with real users. | | **`ACCESS_TOKEN` / `ACCESS_TOKENS`** | Static bearer fallback for MCP/connect clients that cannot use OAuth. Not browser auth and never a token login page. | | **Custom** | Pass your own `getSession` to `autoMountAuth(app, { getSession })`. | Auth emails (verify signup, reset password, magic link, email change) and org invites render through `renderTransactionalEmail`. An app changes their copy or design with `overrideTransactionalEmail(id, render)` from a server plugin, never by editing `better-auth-instance.ts` call sites. See `/docs/deployment#email-templates`. ## Hosted Sign-In Pages and the Shared Wave - First-party server auth plugins use `createToolkitAuthPlugin` from `@agent-native/toolkit/app/auth/server`. It server-renders `AuthPage` and `ResetPasswordPage` before hydration. Direct Core `createAuthPlugin` calls leave generic fallback markup in the response and can flash before React replaces it. - `AuthPage` uses Toolkit's full-page `WaveBackground` for every auth view. The Agent-Native homepage hero and Calendar booking use the same renderer: Calendar's animated FFT ocean wave. Keep the background empty while WebGPU support is checked and when the ocean renderer is unavailable; never show a WebGL or Starfield fallback. The homepage positions the wave inside its hero section, and hosted auth pages lift it above the lower marketing copy. Do not substitute a gradient, a signup-only strip, or a copied renderer. `StarfieldBackground` is only a compatibility export for older callers. - Hosted marketing apps keep auth enabled at `/` so the public root response contains the full server-rendered sign-in page. Keep session decisions out of public SSR; `RequireSession` resolves signed-in app navigation in the client. > **Never** use `local@localhost` as a fallback identity in app code > (`getRequestUserEmail() ?? "local@localhost"`, `session?.email ?? "local@localhost"`, > etc.). There is no dev auth shim. That pattern pools every unauthenticated > request into one shared tenant and caused the 2026-04-29 credentials leak. > When there is no session, **throw or return 401** — never substitute a > sentinel. Enforced by `scripts/guard-no-localhost-fallback.mjs`. ## Remote MCP OAuth Every app's `/mcp` endpoint is also a standard protected MCP resource. OAuth-capable hosts connect with the remote MCP URL only, receive a `WWW-Authenticate` challenge, discover `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`, then use a validated Client ID Metadata Document when the client id is an HTTPS URL or dynamically register a public client otherwise. URL-like client ids never fall back to dynamic registration when their metadata is missing or invalid. Clients complete authorization-code + PKCE at `/mcp/oauth/authorize` / `/mcp/oauth/token`. Access tokens are audience-bound to the exact MCP URL and carry user/org identity plus `mcp:read`, `mcp:write`, `mcp:apps`, and/or `offline_access`; advertising `offline_access` lets hosts such as ChatGPT retain refresh access. Refresh tokens are stored hashed and are not rotated: a refresh returns the same token, with no fixed expiry. MCP OAuth and connect tokens carry the org chosen when they were issued, so `verifyAuth` and the token endpoint re-check live org membership on every use. A removed member gets a 401 or `invalid_grant`; a failed check answers a retryable 503. Offboarding revokes their MCP refresh and connect tokens instead of transferring them. Connect mints MCP OAuth access tokens too, whatever `A2A_SECRET` holds. Resolve the audience of every bearer this app mints through `getMcpOAuthIssuer` / `resolveMcpOAuthIssuer`, whose resources are the audiences `verifyAuth` accepts, never from a URL built from request headers. Org service tokens carry a credential version earlier verifiers reject, because those admit any MCP OAuth token as a verified user. `verifyAuth` classifies a bearer by the credential it claims to be: OAuth access tokens and both connect formats are verified and admitted through one path before any A2A rule runs, and one that fails verification never reaches the A2A checks. Earlier A2A-format connect tokens verify only with the deployment `A2A_SECRET` and take their identity from their stored row; never let organization-secret or organization-principal rules see them. A refused bearer token gets a typed `reason` in the 401 body and, on `/mcp`, an `error_description` in the challenge. Cross-app A2A tokens are not re-checked, because their `org_id` is the signing app's assertion, and the A2A endpoint rejects MCP credentials. Keep `ACCESS_TOKEN` and `pnpm exec agent-native connect` for local stdio proxying and fallback clients. The CLI uses the OAuth-native URL-only entry for Claude Code/Claude Code CLI by default; use the Connect page or `npx @agent-native/core@latest connect --token <token>` when a client needs explicit bearer headers. ## Local → Real Account Migration Upgrading from `local@localhost` to a real account preserves SQL-backed workspace data. The built-in migration moves `application_state`, user-scoped `settings`, `oauth_tokens`, and any template table that uses `owner_email`. Templates with legacy global settings can provide `POST /api/local-migration` for one-time re-homing during the upgrade flow. ## Organizations Organizations are **framework-managed**, not handled by Better Auth's organization plugin (which is intentionally NOT registered). Org data lives in the framework's own `organizations`, `org_members`, and `org_invitations` tables. Every app supports creating orgs, inviting members, and role-based access (owner/admin/member). Owners and admins can require Google sign-in for an organization from Team settings or `PUT /_agent-native/org/auth-provider` with `{ provider: "google" }`. Enabling it revokes current Better Auth and legacy sessions; password signup, password login, and non-Google social sessions are rejected for that org. Agents manage membership through two core actions in the `orgAdministration` tool group. `list-organization-members` reads members, and pending invitations for owners and admins. `invite-organization-member` sends one member invitation through the same path as Settings > Members. It emails a real person, so it sets `needsApproval: true` with `allowPersistentApproval: false`: every call asks the user, and "always allow" is not offered. It cannot grant admin; owners invite admins from settings. Role changes, removal, and revoking an invitation have no agent tool. Keep it that way unless the new tool follows the same approval rule. The active org flows automatically: `session.orgId` — resolved by `getOrgContext` from `org_members` plus the user's `active-org-id` setting (_not_ from a Better Auth session field) — → `AGENT_ORG_ID` → SQL scoping (see `security` skill). When an authenticated user has no org memberships, the framework auto-creates a default org (named after the user) the first time `getOrgContext` runs. This keeps org-scoped templates from showing a manual "create organization" step. The auto-create path skips users with pending invites or a matching `allowed_domain` org so they can join the intended team instead. Set `AUTO_CREATE_DEFAULT_ORG=0` only for deployments that intentionally want manual org creation. ### App-Specific Roles When a request needs "only some teammates may do this **inside this app**", the answer is per-app roles — **not** a second organization. App roles are an overlay on the one org membership, never a second roster. Three role systems coexist and never imply one another: the org role (`org_members.role`) says what a person may do to the *team*, an app role says what they may do inside *one app*, and a share role (`sharing` skill) says what they may do to *one row*. An app `admin` is not an org admin, and org handlers never read app roles. Declare the vocabulary once on the server and guard actions with it: ```ts import { defineAppRoles } from "@agent-native/core/org-team"; export const coachAccess = defineAppRoles({ appId: "coach", roles: ["member", "approver", "coach-admin"] as const, defaultRole: "member", permissions: { approve: ["approver", "coach-admin"] as const }, }); // in an action authorize: coachAccess.requireAny("coach-admin"), ``` For a role-configurable permission, guard instead with `authorize: coachAccess.requirePermission("approve")`. Roles are an unordered set, not a ladder — declaration order carries no meaning, and every guard names its accepted roles explicitly. `defaultRole` is **display only**: `requireAny` and `requirePermission` match explicit assignment rows and nothing else, so "nobody assigned this person" never reads as "granted", and widening the default cannot silently widen a guard. Org membership is a precondition, resolved in the same statement as the assignment, so a leftover assignment for a removed member can never authorize. Only org owners/admins may assign app roles; render the picker with `<TeamPage appRoles={descriptor} />`. In Settings, register a replacement `members` page that renders `<OrgMembersPage appRoles={descriptor} />` instead. Members may have multiple roles. `resolve` returns `{ status: "assigned", roles }`, and `assertAny` accepts any intersection with its requested roles. Declare permission keys and default role grants in the descriptor; owners/admins can override the role grants per organization from TeamPage or through the `list-app-permissions` / `set-app-permission-roles` actions. `requirePermission` uses the org override when present and code defaults otherwise. Unknown permission keys and roles are rejected; an empty override denies that permission to every role. `set-app-member-roles` replaces the member's full role set. Invitations can carry app roles, which apply after the member joins. `requireAny` validates at definition time — no roles, or a role outside the declared vocabulary, throws when the module loads rather than surfacing as an unexplained 403 later. When calling `resolve`/`assertAny` directly, note that an explicit `userEmail: null` / `orgId: null` means "this run has no identity" and does **not** fall back to the ambient request context; only `undefined` does. ### Stop And Confirm Before Creating Or Switching Organizations Vault credentials are scoped per organization and are **not** shared between them. A second organization therefore orphans every key synced under the first, and the only symptom is a missing-credential error somewhere else entirely, naming the key rather than the org change that caused it. So: **do not create an organization, repoint anyone's `active-org-id`, or migrate a user/roster/identity list into a new organization on your own initiative.** Stop, say plainly that it will orphan the existing organization's credentials, and get an explicit yes first — even when the user asked for something that seems to imply it ("use the real org user list", "align this to the Settings team view", "migrate my users"). The intended pattern is **one organization per workspace**, with every app sharing it. When a request needs real org members, add them to the existing organization; never provision a parallel one per app. `createOrganization()` logs a loud warning when it creates an additional org for an account that already belongs to one, and `setActiveOrgId()` logs one whenever it moves an account from one org to another, naming both orgs and the orphaned credentials. Treat either warning as a bug report against your own change, not as noise. Write `active-org-id` only through `setActiveOrgId(email, orgId, reason)` from `@agent-native/core` (`src/org/active-org.ts`). Calling `putUserSetting(email, "active-org-id", ...)` directly is how a roster migration silently repointed 21 accounts with nothing in the logs; the helper exists so that cannot happen again. Do not wrap normal app shells in `<RequireActiveOrg>` just to force setup. Use non-blocking org UI such as `InvitationBanner`, `OrgSwitcher`, and a `/team` route so users can accept invites, join domain-matched teams, or switch orgs without blocking the primary product experience. Place org UI inside the agent sidebar so the setup checklist, chat, and CLI stay usable during setup. ## Org Service Principals An org service token authenticates as `svc-<name>@service.<orgId>`, always an implicit `member`. Its governance record (`org/service-principal-policy.ts`) names an accountable `ownerEmail`, `team`, `riskTier`, `purpose`, a `lifecycle` (`active` | `suspended` | `retired`), and `allowedActions`. - Read the record through `evaluateServicePrincipal` at every entry point that admits a service identity; never query the table directly. - `allowedActions: null` is unrestricted, and a list is deny-by-default (exact names or a trailing `*` prefix). An empty list grants nothing. - The grant is enforced at the action boundary (`defineAction` wrapper and agent tool execution via `enforceServicePrincipalActionGrant`), so MCP, HTTP action routes, and delegated `ask-agent` runs all honor it. Agent built-ins are covered, so a list grant must name them. - No record means an ungoverned legacy principal: still active and unrestricted. Tightening it is an explicit admin act. - An unreadable record fails closed with a 503. Never map a read failure to "ungoverned" or "active". - `create-org-service-token`, `set-service-principal-policy`, and `set-service-principal-lifecycle` are owner/admin only and set `agentTool: false`, which also keeps them off MCP. `toolCallable: false` only blocks extensions and run-code; it does not take an action out of the agent's tools. Suspending or retiring also aborts the principal's in-flight runs. Never let the agent or a service token mint a credential or widen its own grant. ## A2A Identity Set a distinct `A2A_SECRET` (same value) on apps that must verify each other's user identity. Only this deployment secret may sign a per-user A2A assertion; mint it from the authenticated request context. The shared organization `a2a_secret` proves organization scope only. Its subject/email claims are ignored, so it cannot impersonate a member or carry human approval into A2A or MCP. Keep each organization's secret different from `A2A_SECRET`. - Outbound calls sign user assertions with `A2A_SECRET` and verify them cryptographically at the receiver. - Organization-secret calls run as an organization principal without a user email; user-owned reads, tasks, and approvals need a verified user assertion. - Without a verifiable credential, production rejects A2A calls; local dev can remain open. ## Cross-App SSO (Dispatch identity hub) Each hosted `*.agent-native.com` app has its **own user store**, so "sign in once" is identity federation, not a shared cookie. **Dispatch is the identity authority.** - **Canonical hosted apps:** exact registered `*.agent-native.com` clients can show "Sign in with Agent-Native" and silently probe for an existing Dispatch session. The silent browser handoff is controlled by Dispatch's default-off `browser.identity-sso` feature flag. Self-hosted apps opt in with `AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com`; unset means zero behavior change. - **Flow:** the app creates a bound state and PKCE verifier, then opens `GET <hub>/_agent-native/identity/authorize?response_type=code&app=&client_id=&redirect_uri=&state=&code_challenge=`. Dispatch authenticates the human and redirects back with only a short-lived, one-time authorization code. The app keeps the verifier in a callback-scoped HttpOnly cookie and redeems the code server-to-server at `/_agent-native/identity/token`; only that response contains the short-lived `A2A_SECRET`-signed identity assertion. No bearer token, JWT, or wildcard cookie is placed in a browser URL or shared across app domains. Canonical clients require an exact registered app ID, client ID, origin, and callback path; localhost remains available for development. The app verifies the assertion, **JIT-links strictly by verified email** (existing same-email user → reused unchanged; new email → created), then mints a normal local session. - **Organization federation:** the default-off `organization.cross-app-federation` flag separately enables signed org context. A source app registers its local organization with Dispatch using the stable `(identity_authority, identity_id)` mapping; the target app receives the same canonical `org_id`, signed org name, and signed role in the server-to-server assertion, then creates or links its local organization and membership by ID. Names, email domains, and browser cookies never identify an organization. Existing local organizations without a durable mapping are left unchanged rather than guessed or merged; link those explicitly as a migration. Unlinked local organizations remain usable during a Dispatch outage, while linked memberships fail closed until the authority is reachable; registration retries on a later org read. Browser SSO continues to use `A2A_SECRET`, but federation requests use a separate per-app credential: set `AGENT_NATIVE_IDENTITY_FEDERATION_SECRET` on each source app and the matching `AGENT_NATIVE_IDENTITY_FEDERATION_SECRET_<APP_ID>` on Dispatch. Never reuse the shared `A2A_SECRET` for the federation authority boundary. - **Silent browser handoff:** canonical auth pages may pass `prompt=none`. Dispatch returns `login_required` when no Dispatch session exists instead of showing another login page; the app then leaves the local sign-in form available. It never reveals whether a particular email or account exists. The feature flag is evaluated only at Dispatch, and unreadable or off rollout state fails closed. - **Invariant (do not break):** identity rows are only ever **added** - never modified, renamed, or deleted. Enabling SSO logs users out, but they always log back into the **same email-matched account with data intact**. Email is the identity trust boundary; org authorization crosses only as a verified, exact-ID assertion from the registered Dispatch authority. Local org mappings and memberships must not be inferred from names, domains, or user-controlled request bodies.
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기