Project instructions for AI coding tools (Claude Code, Cursor, Gemini, Copilot).
Detailed conventions live in .claude/skills/authorizer-*/SKILL.md and load on demand when matching files are touched. Role-specific expertise lives in .claude/agents/.
Open-source, self-hosted authentication and authorization server. Supports 13+ databases, OAuth2/OIDC, social logins, MFA, magic links, role-based access, webhooks, and email templating.
Stack: Go 1.24+, Gin, gqlgen, GORM, zerolog, Cobra CLI, JWT, OAuth2/OIDC, gRPC (+ grpc-gateway REST), buf/protobuf.
v2: CLI flags for all config — no .env or OS env vars.
make dev # Run server locally (SQLite, embedded dev RSA keys)
make build # Cross-compile server binary → build/{os}/{arch}/authorizer
make build-app # Build login UI (web/app)
make build-dashboard # Build admin UI (web/dashboard)
make all # build + build-app + build-dashboard
make bootstrap # Install gox (required by make build)
make clean # Remove build/make build-local-image # docker build (IMAGE defaults to quay.io/authorizer/authorizer:$(VERSION))
make build-push-image # Multi-arch buildx push
make trivy-scan # Scan Docker image for HIGH/CRITICAL CVEs (IMAGE= override)VERSION defaults to 0.1.0-local; override with make build VERSION=1.2.3.
make generate-graphql # Regenerate gqlgen output after schema.graphqls change; runs go mod tidy
make generate-db-template # Scaffold new storage provider: make generate-db-template dbname=foo
make proto-gen # buf generate → gen/ (installs buf if missing)
make proto-lint # buf lint on proto/
make proto-breaking # Breaking-change check vs origin/main (override: BUF_BREAKING_AGAINST)
make proto-check # proto-gen + fail if gen/ is stale (CI)
make proto-tools # Install buf onlyAfter editing internal/graph/schema.graphqls → make generate-graphql.
After editing proto/ → make proto-gen and commit gen/.
make fmt # fmt-go + fmt-ts
make fmt-go # gofmt -s (excludes gen/)
make fmt-ts # Prettier on web/app and web/dashboard
make lint # lint-go + lint-ts
make lint-go # golangci-lint (installs if missing; excludes gen/ via .golangci.yml)
make lint-ts # Prettier --check on both web apps
make lint-tools # Install golangci-lint onlyRun make fmt before committing; CI runs make lint.
make test # Full module test run; TEST_DBS=sqlite (integration tests always SQLite)
make test-sqlite # Same as test — explicit SQLite-only, no Docker
make test-all-db # All 7 DBs via Docker (postgres, sqlite, mongodb, arangodb, scylladb, dynamodb, couchbase)
make smoke # Release e2e smoke tests (build tag `smoke`, 5m timeout)
# Single-DB targets (each spins up Docker, runs tests, tears down)
make test-postgres
make test-mongodb
make test-scylladb
make test-arangodb
make test-dynamodb
make test-couchbase
# Docker helpers for test-all-db
make test-docker-up # Start all test DB containers + Redis
make test-cleanup # Remove all test containers
make test-cleanup-postgres | test-cleanup-mongodb | test-cleanup-scylladb
make test-cleanup-arangodb | test-cleanup-dynamodb | test-cleanup-couchbaseTest env vars:
TEST_DBS— comma-separated list for storage provider tests (e.g.sqlite,postgres,mongodb). Defaults to all when unset in storage tests.TEST_ENABLE_REDIS=1— include Redis memory_store tests (skipped by default).
Single test (integration tests use SQLite via getTestConfig()):
go clean --testcache && TEST_DBS="sqlite" go test -p 1 -v -run TestSignup ./internal/integration_tests/Use -p 1 for integration tests (shared state). Storage tests honour TEST_DBS.
Verifying a change before calling it done — run these, in order, and only report success after they actually pass in this session:
go build ./...— compiles.go vet ./...— catches suspicious constructs (nil-deref-prone patterns, unreachable code, struct-copy issues).make test(or the single-test command above for a targeted case) — SQLite integration suite passes.- Storage-layer changes only: also run at least one non-SQL backend (
make test-mongodb,make test-arangodb, etc.) ormake test-all-db— a fix verified only on SQLite has not verified cross-DB parity, and this repo's whole storage layer is defined by that parity holding. make lint— must be clean before opening a PR; CI enforces it.
Smoke tests (make smoke → internal/e2e/smoke_test.go, build tag smoke): black-box tests that build the real authorizer binary, boot it as a subprocess, and exercise every public API surface (GraphQL, REST, gRPC, MCP) end to end, including an authenticated FGA decision on each surface. Excluded from normal go test ./... runs (separate build tag) because they compile a binary and bind real ports — run them before a release, or whenever touching startup/wiring (cmd/root.go, internal/server/, internal/grpcsrv/).
E2E tests: internal/e2e/ is the e2e suite, run via make smoke — there is no separate e2e target in this repo; "e2e" and "release smoke" are the same tests.
Initialization order (cmd/root.go): Config → Storage → MemoryStore → Token → Email/SMS → OAuth → Events → HTTP/GraphQL → gRPC → Server
Key paths:
- GraphQL schema:
internal/graph/schema.graphqls| Resolvers:internal/graphql/| Business logic:internal/service/ - gRPC:
proto/authorizer/v1/→gen/| Server:internal/grpcsrv/| REST via grpc-gateway - gRPC auth docs: authorizer-docs repo,
specs/GRPC_REST_API_SPEC.md(§2.3 client metadata) - Request principal (gRPC interceptor):
internal/authctx/ - Storage interface:
internal/storage/provider.go| SQL:internal/storage/db/sql/| NoSQL:mongodb/,arangodb/,cassandradb/,dynamodb/,couchbase/ - FGA engine:
internal/authorization/ - OAuth/OIDC endpoints:
internal/http_handlers/ - Token management:
internal/token/ - Tests:
internal/integration_tests/| Release smoke:internal/e2e/ - Frontend:
web/app/(user UI) |web/dashboard/(admin UI)
Transport pattern: Handlers (GraphQL, gRPC, REST) are thin; shared logic lives in internal/service/ behind Provider / AdminProvider interfaces. gRPC uses transport.MetaFromGRPC to build RequestMetadata; the auth interceptor attaches authctx.Principal before handlers run.
Provider pattern: Every subsystem uses Dependencies struct + New() → Provider interface.
Request handlers and service methods fire side effects (email/SMS send, webhook events, audit logs) as detached goroutines so the response isn't blocked. Never use a bare go func() { ... }() for this — use asyncutil.Go(log *zerolog.Logger, fn func()) (internal/asyncutil/) instead:
- Tracks the goroutine so
internal/server/server.go's graceful shutdown (asyncutil.Wait) drains in-flight background work before the process exits. A barego func()is invisible to shutdown and gets killed mid-flight. - Recovers any panic in
fnand logs it. A barego func()that panics takes down the entire process — Go's default behavior for an unrecovered goroutine panic is to crash the whole binary, not just the request. - Pass the call site's
*zerolog.Logger(p.Log/h.Log), not a locallogvalue, so a recovered panic is still logged correctly.
Only wrap detached, request-scoped, one-shot work this way (fire once, no further caller interaction). Do NOT wrap long-running loops already tied to ctx (HTTP/gRPC listeners, ticker-based cache eviction) — those have their own lifecycle via ctx.Done()/Shutdown() and don't need draining.
When the background closure needs a context, use context.WithoutCancel(ctx) (not context.Background()) so log/trace correlation carries over without inheriting the request's cancellation — this is the existing convention throughout internal/service/.
- Every
*gorm.DBcall must use.WithContext(ctx)— never a barep.db.Where(...)with no context — so cancellation, timeouts, and tracing propagate. - Multi-step writes that must be atomic (cascade deletes, multi-table inserts) MUST be wrapped in
p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { ... }), and every call inside the callback must usetx, never the outerp.db— reaching for the outer handle inside a transaction silently escapes it and defeats the atomicity. - Bulk operations (e.g. an
UpdateUsers-style method) must reject an empty/unscoped filter rather than silently applying to every row — verify this contract holds identically across all provider implementations (SQL, MongoDB, ArangoDB, Cassandra/ScyllaDB, DynamoDB, Couchbase), not just SQL. A safety guard present in one backend and missing in another is a parity bug. - Any indexed lookup added to one provider (e.g.
GetUserByExternalID) needs the matching index/equivalent in every other provider — a method that's O(1) in one backend and a full collection/table scan in another is a parity bug, not a perf nit. - Never build a query by string-concatenating or
fmt.Sprintf-ing request-derived values into a WHERE/SET/CQL clause, even for NoSQL backends — always parameterize/bind, and never let a map of column names sourced from a request reach a query builder without an explicit allow-list.
- Admin GraphQL ops prefixed with
_— not for public use. Same forAuthorizerAdminServicegRPC. - Schema changes MUST update all 13+ database providers.
- Run
make generate-graphqlafter editingschema.graphqls. - Run
make proto-gen(ormake proto-check) after editingproto/; commitgen/. - NEVER commit to main — always use a feature branch (
feat/,fix/,security/,chore/), push, open a PR. Main must stay deployable. - Fire-and-forget side effects go through
asyncutil.Go, never a barego func()— see Background Work above.
Detailed rules load via skills (see below) — don't restate them here.
| Agent | Model | Focus |
|---|---|---|
principal-engineer |
opus | Full SDLC: Plan → Execute → Test → Review across Go, storage, GraphQL, HTTP, gRPC. Use for any change touching >1 subsystem. |
security-engineer |
opus | OAuth2/OIDC, JWT, MFA, vulnerability audit. Second-pass on auth-sensitive PRs. |
doc-writer |
haiku | API docs, guides, migration docs. |
authz-researcher |
opus | Deep, adversarially-verified research on authz standards (OpenFGA, RFC 8693/8707/9728, MCP, CIBA, AuthZEN). Run before designing/building any authz capability. |
fga-engineer |
opus | Implements the OpenFGA migration (Wave 1) per specs/FGA_OPENFGA_MIGRATION_PLAN.md (authorizer-docs repo). |
delegation-engineer |
opus | Implements the agentic delegation chain (Wave 2) per specs/AGENTIC_DELEGATION_DESIGN.md (authorizer-docs repo). Security-critical. |
| Skill | Fires when editing |
|---|---|
authorizer-go-conventions |
any *.go file |
authorizer-graphql |
internal/graph/, internal/graphql/, schema.graphqls |
authorizer-storage |
internal/storage/ (any provider) |
authorizer-http-handlers |
internal/http_handlers/ |
authorizer-security |
auth-sensitive code or security/ branches |
authorizer-testing |
any *_test.go |
authorizer-frontend |
web/app/, web/dashboard/ |
openfga-modeling |
FGA engine (internal/authorization/), authz models/tuples, check_permissions/list_permissions/_fga_* GraphQL |
agentic-auth-standards |
token exchange, delegation, MCP, agent-identity (internal/token/, internal/http_handlers/) |
- Detailed rules live in skills — they only load when relevant files are touched.
- Agent definitions only load when invoked via the
Tasktool. - Go files auto-formatted on save via hook (no formatting discussion needed).
- Use
Grep/Globbefore exploring — avoid speculative file reads. - Prefer reading specific line ranges over full files.
Behavioral guidelines to reduce common LLM coding mistakes.
Don't assume. Don't hide confusion. Surface tradeoffs. Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it. Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
Touch only what you must. Clean up only your own mess. When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it. When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
Define success criteria. Loop until verified. Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
- [Step] → verify: [check]
- [Step] → verify: [check]
- [Step] → verify: [check]