ワンクリックで
auth
Authentication, authorization, and multi-tenancy — forge.yaml config, JWT, API keys, RBAC, dev mode, and tenant scoping.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Authentication, authorization, and multi-tenancy — forge.yaml config, JWT, API keys, RBAC, dev mode, and tenant scoping.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Visual-design discipline for forge frontends — brief the work before building (never invent an aesthetic without user input), declare the design system before coding, lean on the component library, use restrained color/type systems, never hand-draw complex SVGs, and verify visually before declaring done.
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Outbound boundary translators. One adapter per third-party system / queue / storage backend; narrow interface, vendor-neutral callers.
Write Connect RPC handlers — proto-driven codegen, the thin-translation handler pattern (validate, extract auth, convert proto↔internal, call service, wrap errors via `svcerr.Wrap`), middleware, and testing. Business logic lives in `internal/handlers/<svc>/contract.go`, never in handlers.
Forge project conventions and architecture — project structure, generated vs hand-written code, the generate pipeline, proto annotations, contracts, wiring, and naming.
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
| name | auth |
| description | Authentication, authorization, and multi-tenancy — forge.yaml config, JWT, API keys, RBAC, dev mode, and tenant scoping. |
Forge generates a layered auth system from proto annotations and forge.yaml config. Authentication (who are you?) is handled by interceptors; authorization (can you do this?) is handled by per-service authorizers.
Set the provider in forge.yaml:
auth:
provider: jwt # "jwt", "api_key", "both", "none"
jwt:
signing_method: RS256
jwks_url: "https://your-idp.com/.well-known/jwks.json"
issuer: "https://your-idp.com/"
audience: "your-api"
api_key:
header: X-API-Key # default
jwt-auth pack for production-ready validation.X-API-Key). Install the api-key pack for key lifecycle management.Claims is the canonical auth payload, defined in forge/pkg/auth and aliased in internal/middleware/middleware.go (the thin user-owned auth-policy file — it also owns the claims context key):
// internal/middleware/middleware.go (user-owned, scaffolded once)
package middleware
import "github.com/reliant-labs/forge/pkg/auth"
type Claims = auth.Claims
// forge/pkg/auth/auth.go
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
OrgID string `json:"org_id"`
Role string `json:"role"`
Roles []string `json:"roles"`
}
Retrieve claims in handlers:
claims, ok := middleware.ClaimsFromContext(ctx)
if !ok {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("no claims"))
}
If you need additional claim DATA, prefer the enrichClaims hook in internal/middleware/middleware.go — it runs after token validation and can hydrate roles/org/flags onto the validated claims before handlers see them. If you need additional claim FIELDS, add them to forge/pkg/auth.Claims so library code (auth interceptor, tenant interceptor) and project code share one type. Project-local extensions are not supported by the alias-based wiring; if you must, replace the type Claims = auth.Claims line with a struct that embeds auth.Claims and update the callbacks the file passes to the forge libraries.
The auth MECHANISM (mode resolution, refusal-to-start, allow-list gate, Bearer parsing, claims plumbing) lives in forge/pkg/authn; the authorization interceptor in forge/pkg/authz; commodity middlewares (CORS, security headers, request-id, rate limit, idempotency, HTTP stack, audit) in forge/pkg/middleware. The project keeps ONE scaffolded-once file, internal/middleware/middleware.go, wiring the four things projects actually customize:
NewAuthInterceptor(AuthDeps{Validate: fn}); there is no package-global slot. The composition root (OpenInfra in internal/app/providers.go, or the generated auth_gen.go) builds the validator and the generated cmd serve.go threads it into AuthDeps.Validate before the interceptor chain is assembled.enrichClaims(ctx, claims) runs after validation; hydrate roles/org membership/feature flags from your DB here. Errors reject the request.unauthenticatedProcedures, exact full-procedure strings only.devClaims() returns the synthetic principal attached while auth is off (dev mode / AUTH_MODE=none). The scaffolded default is a fixed dev user (UserID: "dev-user", Email: "dev@localhost", Role: "admin") so claim-demanding handlers (generated CRUD calls GetUser) work in dev with zero config; return nil to keep dev passthrough claim-free. Ignored entirely in validate/external-auth modes.The file also owns Claims, the claims context key (ClaimsFromContext / ContextWithClaims), and the Authorizer interface + DevAuthorizer that generated code references — so regenerating never churns your handler-facing surface.
Auth mode resolution (in forge/pkg/authn, decided ONCE at interceptor construction): AuthDeps.Validate non-nil → validate every non-allow-listed request; AuthDeps.ExternalAuth true (a header-carried provider or a pack mounts its own interceptor) → passthrough; AUTH_MODE=none → passthrough; dev mode (AuthDeps.DevMode, injected from config.DevAuthBypass(cfg)) → passthrough; otherwise the server refuses to start.
When forge.yaml declares auth.provider, the generated
internal/middleware/auth_gen.go is a thin shim over forge/pkg/auth.
The generated cmd serve.go calls middleware.InstallGeneratedAuth()
as it wires the interceptor chain and threads the result into
middleware.AuthDeps, except under an auth bypass
(config.DevAuthBypass(cfg) — the dev-claims passthrough applies
instead). The auth provider is wired EXPLICITLY where the chain is
composed — not via a package-global setter and not via a post-bootstrap
hook reading off an App value.
provider: jwt — InstallGeneratedAuth constructs the
pkg/auth validator from the forge.yaml config and RETURNS its
Validate func, which the generated cmd serve.go threads into
AuthDeps.Validate; it also merges the proto-annotated
auth_required = false procedures into unauthenticatedProcedures.
The user-owned policy surface (enrichClaims, allow-list, dev claims)
stays fully in the loop; there is no parallel interceptor.provider: api_key / both — API keys arrive in a header, not
a bearer token, so InstallGeneratedAuth returns a nil validator and
the generated cmd serve.go sets AuthDeps.ExternalAuth = true and
mounts the header-aware middleware.GeneratedAuthInterceptor().
API-key requests FAIL CLOSED until you implement KeyValidator
(internal/middleware/auth_validator.go) and install it from
OpenInfra in internal/app/providers.go:// internal/app/providers.go — in OpenInfra, before the chain is built
middleware.SetGeneratedKeyValidator(&DBKeyValidator{db: repo})
KeyValidator is aliased to auth.KeyValidator; implement ValidateKey(ctx, key) (*Claims, error) against your storage.
The library reads JWT_SECRET from the environment when JWTConfig.Secret is empty (preserves the legacy template behaviour).
The interceptor chain is assembled explicitly in the generated
cmd serve.go via observe.Chain(observe.Deps{…}), with the
application interceptors handed in BY NAMED FIELD (no Set* package
globals). The canonical forge order is the code's, not a side effect of
registration order: recovery → request-id → logging → tracing →
metrics (the observability layer), then auth → audit → rate-limit
(the application layer); otelconnect rides Extras (outermost of the
application layer).
Auth is one of those named fields, so failures from the auth interceptor
are still observable (counted, traced, logged). The auth interceptor is
built UP FRONT from an explicit AuthDeps — an unconfigured auth
provider is a startup error, not a per-request surprise — then passed
into observe.Chain:
// In cmd serve.go. AuthDeps carries the explicit policy (DevMode +
// the validator/external signal); NewAuthInterceptor REFUSES TO START
// a production server that has no auth provider.
authDeps := middleware.AuthDeps{DevMode: config.DevAuthBypass(cfg)}
// (auth_gen.go threads the forge.yaml provider's validator into authDeps here)
authInterceptor, err := middleware.NewAuthInterceptor(authDeps)
if err != nil {
return fmt.Errorf("auth configuration: %w", err)
}
chainDeps := observe.Deps{
Logger: logger,
Auth: authInterceptor, // ← auth, named field
Audit: fmw.AuditInterceptor(logger, middleware.ClaimsFromContext),
// RateLimit: fmw.RateLimitInterceptor(...), // when configured
}
opts := []connect.HandlerOption{
connect.WithInterceptors(observe.Chain(chainDeps)...),
}
The ordering puts auth-after-observability deliberately — operators want
auth failures in the same dashboards as successful traffic. See
forge/pkg/observe/middleware.go for the full ordering rationale.
The auth interceptor checks an allow-list before requiring auth (exact procedure match only — the gate lives in forge/pkg/authn). To allow additional unauthenticated endpoints, add them to the unauthenticatedProcedures map in internal/middleware/middleware.go:
var unauthenticatedProcedures = map[string]struct{}{
"/grpc.health.v1.Health/Check": {},
"/grpc.health.v1.Health/Watch": {},
"/myapp.v1.PublicService/GetStatus": {}, // add here
}
Annotate RPC methods with auth_required in your proto (there is no
required_roles annotation — role logic is code, not proto):
rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse) {
option (forge.v1.method) = {
auth_required: true
};
}
forge generate produces internal/handlers/<svc>/authorizer_gen.go with the per-method policy table (auth-required flags and declared error codes). Customize access control — including role checks — in internal/handlers/<svc>/authorizer.go (yours to edit; delegates to the generated authorizer by default). The generated and owned files co-locate in the one internal/handlers/<svc>/ directory.
No auth-by-omission. An RPC with no (forge.v1.method) annotation has no declared auth posture. forge lint flags this and the policy table defaults the method to deny until you annotate it — a missing annotation must never silently generate an unauthenticated endpoint.
forge up --env=dev defaults the children to ENVIRONMENT=development when nothing else sets it (per-env config or your shell always wins), so the canonical dev command never boots the server in production mode — where an unconfigured auth provider would refuse to start.
In dev mode (or AUTH_MODE=none) the auth interceptor runs in passthrough and attaches the synthetic principal from devClaims() in internal/middleware/middleware.go to every request, so handlers and generated CRUD that demand claims via middleware.GetUser work with zero auth config. To disable, return nil from devClaims() — dev requests then carry no claims and claim-demanding RPCs return Unauthenticated. The dev principal is only consulted in passthrough mode; installing a validator or registering external auth makes pkg/authn ignore it.
Note the split: the jwt-auth pack is for REAL JWT validation (JWKS, issuer/audience checks) — it is not part of the dev path. You do not need any pack to develop locally.
In development (cfg.Environment == "development"), Build also wires a DevAuthorizer that allows all requests. This is logged with a WARN at startup. Never use DevAuthorizer in production.
Enable row-level tenant isolation in forge.yaml:
auth:
provider: jwt
multi_tenant:
enabled: true
claim_field: org_id # JWT claim to extract tenant ID from (default: "org_id")
column_name: org_id # DB column for tenant scoping (default: "org_id")
Run forge generate after changing this config.
How it works at runtime: The TenantInterceptor (runs after AuthInterceptor) extracts the tenant ID from claims and injects it into context. Use middleware.RequireTenantID(ctx) or middleware.TenantIDFromContext(ctx) in handlers.
| claim_field | Claims field used |
|---|---|
org_id (default) | claims.OrgID |
user_id / sub | claims.UserID |
email | claims.Email |
When multi-tenant is enabled, entities with a field explicitly marked tenant: true (the (forge.v1.field) annotation) are automatically scoped — generated CRUD handlers include WHERE <tenant_col> = $tenantID in every query. The tenant annotation must be set explicitly; field names like org_id or tenant_id are NOT auto-detected.
The auth-ui frontend pack pairs with each auth backend pack to install
opinionated login / signup / session UI. Pick the backend first, then
pick the matching --config provider=…:
# Default — pairs with the jwt-auth backend pack
forge pack install auth-ui # provider defaults to jwt-auth
# Pair with the clerk backend pack (pulls in @clerk/nextjs)
forge pack install auth-ui --config provider=clerk
# Pair with the firebase-auth backend pack (pulls in firebase)
forge pack install auth-ui --config provider=firebase-auth
The pack installs into every frontend declared in forge.yaml at
src/components/auth/. It ships:
LoginForm — email/password form (or Clerk/Firebase wrapper) with
react-hook-form + zod validation.SignupForm — registration form, where supported by the provider.SessionNav — header avatar dropdown with sign-out and an optional
tenant switcher.DevModeBanner — visible warning when
NEXT_PUBLIC_AUTH_DEV_MODE=true, mirroring the backend pack's
dev_mode: true setting.auth-store.ts — Zustand store: {user, session, isLoading, isAuthenticated}. Subscribe to slices, never the whole store.Wire in src/app/layout.tsx:
import { DevModeBanner, SessionNav } from "@/components/auth";
export default function RootLayout({ children }) {
return (
<html><body>
<DevModeBanner />
<header className="flex items-center justify-between border-b px-6 py-3">
<span className="font-bold">My App</span>
<SessionNav />
</header>
<main>{children}</main>
</body></html>
);
}
For the jwt-auth variant, also rehydrate the persisted token at app
boot — see the rendered src/components/auth/README.md for the
HydrateAuth snippet. Clerk and Firebase variants manage rehydration
internally.
Inject claims into context directly in tests:
ctx := middleware.ContextWithClaims(context.Background(), &middleware.Claims{
UserID: "user-1",
Email: "test@example.com",
OrgID: "tenant-123",
Role: "admin",
})
For tenant context: ctx = middleware.ContextWithTenantID(ctx, "tenant-123").
Claims struct for custom fields; do not create parallel claim types.authorizer_gen.go is regenerated on every forge generate — customize auth logic in authorizer.go.connect.CodeUnauthenticated for missing/invalid credentials and connect.CodePermissionDenied for insufficient roles.TenantInterceptor must come AFTER AuthInterceptor in the interceptor chain.tenant_gen.go is regenerated by forge generate — do not hand-edit.