LGC — LoginCrew
LGC is the centralized OAuth 2.0 / OIDC authentication service for the JinjiCrew ecosystem. It manages person identity and provides single sign-on (SSO) across all connected product apps.
Key principle: LGC stays pure identity. It knows nothing about companies, products, or subscriptions — that’s ADC’s job.
Services
| Service | Port | Tech | Purpose |
|---|---|---|---|
| lgc_api | 3100 | Go + Echo + MySQL | OAuth backend, identity management |
| lgc_web | 3101 | Next.js + React 19 (npm) | Authentication frontend (login flow) |
| lgc_cp | 3103 | Next.js + React 19 (Bun) | Operator control panel |
Infrastructure: MySQL 8 (port 13306), Valkey (Redis fork, for future caching).
Architecture
LGC is built with domain-driven design. Four bounded contexts, each with domain/app/infra layers:
Dependency flow: infra → app → domain (never the reverse). Cross-domain calls happen at the app layer (e.g., authentication app calls person app). Audit is the sink — everything writes into it, nothing reads from it.
See Architecture for cross-system design patterns.
What LGC Does
- Authentication — multi-step login flow: identify → verify → authorize → consent → token exchange
- SSO — shared
lgc_sessioncookie enables seamless cross-app authentication - Session management — two-layer session model, validation, userinfo, logout
- Registration and provisioning — self-registration and bulk user provisioning with pre-created consents
- Access control —
requires_grantgate and person app grants for restricted apps - Person management — polymorphic identifiers (email, phone, company code, Google)
- OAuth client registry — each product app registers as an OAuth client
- Audit logging — append-only audit, request, and security logs
Data Model
| Table | Domain | Key Columns | Purpose |
|---|---|---|---|
persons | person | xid, name_display, name_legal | User identity records |
identities | person | xid, person_id, type, identifier, challenge | Login methods (email, SMS, Google, company code) — polymorphic |
clients | client | xid, client_id, allowed_scopes, requires_grant | Registered OAuth clients (product apps) |
api_keys | client | xid, client_id, key_hash, role | Service-to-service authentication |
person_app_grants | client | xid, person_id, client_id, access_level | Per-person access grants for restricted apps |
sessions | authentication | xid, person_id, client_id, token_hash | Active auth sessions (hashed tokens) |
authorization_codes | authentication | xid, code_hash, person_id, client_id, scopes | OAuth authorization codes (hashed, single-use, 10-min TTL) |
consent_records | authentication | person_id, client_id, scopes | User consent to OAuth scopes |
audit_log | audit | target_type, target_xid, action, agent_xid | Append-only event trail |
request_log | audit | request_id, method, path, status_code | HTTP request log (query strings stripped) |
security_log | audit | event_type, identifier, ip_address | Failed/suspicious auth attempts |
[!NOTE] XID pattern — every entity table has both
id(internal auto-increment PK) andxid(char(20) external ID via rs/xid ). APIs, JWTs, and logs usexid— internal IDs never cross layer boundaries. FK resolution uses SQL subqueries:(SELECT id FROM persons WHERE xid = ?).
[!NOTE] Hard deletes — LGC uses hard deletes over soft deletes (privacy-first). The audit log preserves the event history. Sessions, authorization codes, and persons are deleted from their tables — no
deleted_atcolumns. Grants are the exception: they use soft-delete via arevoked_atcolumn so revocation history is visible in the control panel (see Access Control).
Scope Model
Scopes flow through three trust boundaries before reaching the consuming app:
- Allowed scopes — the ceiling set on the client. Requested scopes must be a subset.
- Consent — what the person approves on the consent screen. Stored for future logins.
- Auth code — the approved scopes embedded in the single-use authorization code, carried to token exchange.
Security Summary
| Concern | Mechanism | Detail |
|---|---|---|
| Passwords | argon2id | Salted, non-deterministic hash. Raw value never stored. |
| Lookup tokens (sessions, auth codes) | SHA-256 | Deterministic hash for DB lookup. Raw value lives in cookie or shown once. |
| Verification tokens | HMAC | Signed by server, 5-minute TTL. Bridges verify → authorize. |
| Client secrets | argon2id | Hashed at registration, raw shown once. |
| Sessions | 24h absolute + 2h idle | Hard-deleted on logout or expiry. |
| Authorization codes | 10-min TTL, single-use | used_at set on exchange; replay returns error. |
| Audit | Append-only | Three tables, never updated or deleted. |
| Deletes | Hard delete (grants use soft-delete) | Privacy-first. Audit log preserves event history. Grants soft-deleted via revoked_at. |
[!TIP] API reference — full request/response schemas, examples, and error codes are in the rendered API docs .
Client Authentication
LGC has two distinct mechanisms for authenticating callers. They solve different problems and use different hashing strategies.
Client secrets
Standard OAuth 2.0 client authentication. A client secret proves “I am this OAuth client” during token exchange. The client backend sends client_id + client_secret in the POST /auth/token request body.
- Stored on the
clientstable (client_secretcolumn) - Hashed with argon2id (salted, non-deterministic) — same approach as passwords
- Raw value shown once at client registration, never retrievable again
- Confidential clients (server-side apps) have a secret; public clients (mobile) have
NULL - Only used for one endpoint:
POST /auth/token
API keys
Machine-to-machine authentication for role-gated endpoints. An API key proves “I have permission to call this category of endpoints.” Sent via the X-API-Key header, validated by middleware before the request reaches the handler.
- Stored on the
api_keystable (key_hashcolumn) - Hashed with SHA-256 (deterministic) — looked up by hash, not compared like passwords
- Each key belongs to exactly one OAuth client (
client_idFK) - Each key has a role that controls which endpoints it can access
last_used_atbumped on each use
A single client can have one secret and multiple API keys with different roles. For example, jinjicrew might have a client_secret for token exchange, a service key for session validation, and a provisioner key for bulk user creation.
Why two hash strategies
| Mechanism | Hash | Reason |
|---|---|---|
| Client secrets | argon2id | Compared against a known hash (like passwords). Salted to resist rainbow tables. |
| API keys | SHA-256 | Looked up by hash value in the DB. Must be deterministic so the same raw key always produces the same hash for WHERE key_hash = ?. |
See API Key Roles for the full role breakdown.
API Reference
Login Flow
| Endpoint | Method | Purpose |
|---|---|---|
/auth/identify | POST | Parse identifier (email/phone/code), return identity type + challenge method |
/auth/verify | POST | Validate credentials, return verification token (5-min TTL) |
/auth/authorize | POST | Check client + scopes, return authorization code or consent_required |
/auth/consent | POST | Record scope approval, return authorization code |
SSO
| Endpoint | Method | Purpose |
|---|---|---|
/auth/login-session | POST | Create a client-unbound login session (lgc_session cookie) |
Token Exchange
| Endpoint | Method | Purpose |
|---|---|---|
/auth/token | POST | Exchange authorization code for session token (backend-to-backend) |
Session Management
| Endpoint | Method | Purpose |
|---|---|---|
/auth/session | GET | Validate session, bump last_active_at |
/auth/session | DELETE | Revoke session (hard delete) |
/auth/userinfo | GET | Fetch person identity filtered by consented scopes |
Person Management
| Endpoint | Method | Purpose |
|---|---|---|
/persons | POST | Self-register a new person with primary identity |
Provisioning
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/provisioning/persons | POST | API key (provisioner) | Provision user with optional pre-created consent |
/provisioning/persons/{xid}/challenge | PATCH | API key (provisioner) | Change a provisioned user’s password |
/provisioning/persons/{xid}/identifier | PATCH | API key (provisioner) | Change a provisioned user’s identifier |
Admin
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/admin/persons | GET | API key (admin) | List persons (paginated, searchable) |
/admin/persons/{xid} | GET | API key (admin) | Get person with identities and grants |
/admin/persons/{xid} | PATCH | API key (admin) | Update person name fields |
/admin/persons/{xid} | DELETE | API key (admin) | Delete person (hard delete) |
/admin/clients | GET | API key (admin) | List all active clients |
/admin/identities | GET | API key (admin) | List identities for a person |
/admin/identities/{xid} | PATCH | API key (admin) | Update identity identifier |
/admin/identities/{xid}/challenge | PATCH | API key (admin) | Set identity password |
/admin/grants | POST | API key (admin) | Create a person app grant |
/admin/grants | GET | API key (admin) | List grants by person or client |
/admin/grants/{xid} | PATCH | API key (admin) | Update grant access level |
/admin/grants/{xid} | DELETE | API key (admin) | Revoke a grant (soft delete via revoked_at) |
[!TIP] Full API docs — see lgc-api.pages.dev for complete OpenAPI documentation with request/response schemas and examples.
Domain Architecture
internal/domains/
person/ — persons + identities
domain/ — entities, identifier parser, errors
app/ — IdentifyPerson, VerifyChallenge, CreatePerson
infra/ — SQLRepository
authentication/ — sessions, authorization_codes, consent_records
domain/ — entities with IsExpired/IsIdle/IsUsed/CoversScopes
app/ — CreateSession, ValidateSession, RevokeSession,
IssueAuthorizationCode, ExchangeAuthorizationCode,
CheckConsent, RecordConsent
infra/ — SQLRepository
client/ — clients, api_keys, person_app_grants
domain/ — Client (HasRedirectURI, AllowsScopes), APIKey, Grant
app/ — FindClient, AuthenticateClient, grant CRUD
infra/ — SQLRepository
audit/ — audit_log, request_log, security_log (all append-only)
domain/ — AuditLogEntry, RequestLogEntry, SecurityEvent
app/ — RecordAuditLogEntry, RecordRequestLogEntry, RecordSecurityEvent
infra/ — SQLRepositoryDomain dependencies:
| Domain | Depends on |
|---|---|
| authentication | person, client, audit |
| person | audit |
| client | audit |
| audit | nothing (the sink) |
Local Development
cd lgc_main
pairin # Starts db, api, web, cp in split-pane TUIOr manually:
docker compose up # MySQL on port 13306
cd services/api && joka migrate up --auto && go run ./cmd/api # API on 3100
cd services/web && npm install && npm run dev # Web on 3101
cd services/cp && bun install && bun dev # CP on 3103