jwt
Sorcha-Platform/Sorcha/.claude/skills/jwt/SKILL.md
Implements JWT Bearer authentication for service-to-service and user authorization. Use when: Configuring authentication, creating authorization policies, issuing/validating tokens, or troubleshooting 401/403 errors.
Skill2 starsChanged 8 months ago
What's in it
- JWT Authentication Skill
- Tiered audiences + issuer hardening (Spec 136 / Feature 136)
- Quick Start
- Service Authentication Setup
- Protect an Endpoint
- Key Concepts
- Common Patterns
- Custom Authorization Policy
- Tier-aware policy (fold the audience in — Feature 147)
- Resource ownership is NOT a policy — gate it in the handler (issue #1182)
- Extract Claims in Handler
- See Also
- Related Skills
- Documentation Resources
Tools it asks for
- Read
- Edit
- Write
- Glob
- Grep
- Bash
- mcp__context7__resolve-library-id
- mcp__context7__query-docs
---
name: jwt
description: |
Implements JWT Bearer authentication for service-to-service and user authorization.
Use when: Configuring authentication, creating authorization policies, issuing/validating tokens, or troubleshooting 401/403 errors.
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, mcp__context7__resolve-library-id, mcp__context7__query-docs
---
# JWT Authentication Skill
Sorcha uses **JWT Bearer authentication** with the **Tenant Service** as the token issuer. All services validate tokens using shared `JwtSettings` from `Sorcha.ServiceDefaults`. Tokens support three types: user (email/password), service (client credentials), and delegated (service acting on behalf of user).
## Tiered audiences + issuer hardening (Spec 136 / Feature 136)
**The `aud` claim is the trust-tier boundary.** Every token carries an installation-namespaced, tier-scoped audience — `{installation}:consumer | platform | service | enrol-session` — derived from the **single source of truth** `SorchaAudiences` in `Sorcha.ServiceDefaults.Auth`. `InstallationName` (default `sorcha`, overridable per deployment) drives **both** the audience namespace and the issuer. Never hand-build an audience string — use `new SorchaAudiences(installationName).For(Tier.X)` / `.All`.
| Tier | Audience | Who | Claim set |
|------|----------|-----|-----------|
| `Consumer` | `{install}:consumer` | citizen / wallet holder (web + PWA) | `sub`, `email`, `platform_user_id`, `org_id`/`org_name` (home/public org) — **omits `roles`/`wallet_address`**. Inert on platform surfaces by audience + no-roles, not by absence of org context. |
| `Platform` | `{install}:platform` | admin / designer / auditor / org operator | full user shape: + `org_id`, `org_name`, `roles[]`, `wallet_address?` |
| `Service` | `{install}:service` | service-to-service / internal | `client_id`, `service_name`, `scope[]`, `delegated_*?` |
| `EnrolSession` | `{install}:enrol-session` | one-time device pairing | `scope:"enrol"`, single-use JTI |
**Validation = authenticate-broad / authorize-narrow.** The bearer pipeline accepts any of the installation's four tier audiences (`ValidAudiences = SorchaAudiences.All`), rejecting cross-installation tokens. The specific tier is enforced **per endpoint** by policies registered in `AddSorchaAuthorizationPolicies` (called by every service): `RequireConsumerAudience`, `RequirePlatformAudience`, and the extended `RequireService` (now `token_type==service` **AND** `aud==:service`; `CanWriteDockets`/`CanReportRegisterObservation` mirror it). They resolve the installation's `SorchaAudiences` from DI at request time via `TierAudienceAuthorizationHandler` (built from `JwtSettings:InstallationName`) — no per-host wiring needed. Use `AuthorizationPolicyExtensions.HasTierAudience(user, audiences, tier)` to test the predicate.
**Issuer hardening.** No shared default. `SorchaIssuer.Resolve(explicitIssuer, installationName, allowDevLocalFallback)`: explicit wins → `urn:sorcha:{installation}` → (non-prod) `urn:sorcha:dev-local` → otherwise **throws at startup** (fail-closed in Production/Staging). `allowDevLocalFallback = SorchaIssuer.AllowsDevLocalFallback(env)` is true for any non-Production/Staging environment (Development, Testing). Mint side (`TokenService`, `EnrolSessionService` via the Tenant `JwtConfiguration`) and validate side (`AddJwtAuthentication`) MUST resolve issuer + audiences through the **same** `SorchaIssuer`/`SorchaAudiences`, or tokens self-reject.
**Mint mapping today:** `TokenService.GenerateUserTokenAsync` → `platform`; `GenerateServiceTokenAsync` → `service`; `EnrolSessionService` redeem → `consumer`, mint → `enrol-session`. Refresh tokens carry a `tier` claim and re-mint the same tier.
> **Status: all five user stories SHIPPED** (verified 2026-08-02 — `AuthorizationPolicyExtensions` registers `RequireConsumerAudience` / `RequirePlatformAudience`, `RequireService` adds `TierAudienceRequirement(Tier.Service)`, and `IdentityMetrics` is wired in `ServiceDefaults`). Tier selection at login, per-endpoint tier classification, the `:service` audience extension and the `Sorcha.Identity` meter are all live. Spec/plan/tasks: `specs/136-jwt-audience-tiers/`.
>
> This block previously read "not yet landed … endpoints are not yet tier-gated". That was stale and load-bearing in the wrong direction: it tells a reader the tier gate is inert, so they skip adding one. Treat CLAUDE.md §13 and the `sorcha-architecture` skill as co-authoritative and keep all three in step.
**No migration:** coordinated config rollout; existing tokens expire (pre-release).
## Quick Start
### Service Authentication Setup
```csharp
// Program.cs - Any Sorcha service
var builder = WebApplication.CreateBuilder(args);
// 1. Add JWT authentication (shared key auto-generated in dev)
builder.AddJwtAuthentication();
// 2. Add service-specific authorization policies
builder.Services.AddBlueprintAuthorization();
var app = builder.Build();
// 3. CRITICAL: Order matters!
app.UseAuthentication();
app.UseAuthorization();
app.MapBlueprintEndpoints();
app.Run();
```
### Protect an Endpoint
```csharp
// Minimal API pattern
group.MapPost("/", CreateBlueprint)
.WithName("CreateBlueprint")
.RequireAuthorization("CanManageBlueprints");
```
## Key Concepts
| Concept | Usage | Example |
|---------|-------|---------|
| Token Types | Differentiate user vs service | `token_type` claim: `"user"` or `"service"` |
| Organization Scope | Isolate tenant data | `org_id` claim in token |
| Signing Key | Symmetric HMAC-SHA256 | Auto-generated in dev, Azure Key Vault in prod |
| Token Lifetime | Configurable per type | Access: 60min, Refresh: 24hr, Service: 8hr |
## Common Patterns
### Custom Authorization Policy
**When:** Endpoint requires specific claims beyond role-based auth.
```csharp
// AuthenticationExtensions.cs
options.AddPolicy("CanPublishBlueprints", policy =>
policy.RequireAssertion(context =>
context.User.Claims.Any(c => c.Type == "can_publish_blueprint" && c.Value == "true")
|| context.User.IsInRole("Administrator")));
```
### Tier-aware policy (fold the audience in — Feature 147)
**When:** A gate must admit a service-tier caller OR a specific human-tier caller. Because a
**consumer**-tier token also carries `org_id` (Feature 136), a bare `hasOrgId || isService` check
lets a citizen through — the tier **audience** must be part of the gate. Resolve the expected
audience from the DI singleton `SorchaAudiences` (never hard-code the string), so the check lives in
a requirement + handler rather than an inline assertion. `CanManageBlueprints` is the canonical example:
```csharp
// Sorcha.Blueprint.Service/Authorization/BlueprintManagementAuthorizationHandler.cs
// succeed iff (token_type==service AND HasTierAudience(user, audiences, Tier.Service))
// || (org_id present AND HasTierAudience(user, audiences, Tier.Platform))
services.AddSingleton<IAuthorizationHandler, BlueprintManagementAuthorizationHandler>();
options.AddPolicy("CanManageBlueprints", policy =>
policy.AddRequirements(new BlueprintManagementRequirement()));
```
The Wallet Service's `CanRecoverSystemWallet` (system-wallet BIP39 import) follows the same shape:
service-tier **OR** (`Administrator`/`SystemAdmin` role AND `:platform` audience).
### Resource ownership is NOT a policy — gate it in the handler (issue #1182)
**A tier policy answers "what kind of caller is this", never "does this row belong to them".** Both
questions must be answered, and only the first can live in `.RequireAuthorization(...)`. Conflating
them is how `GET /api/instances/{id}` shipped returning any citizen's identity application — name,
date of birth, address and portrait tokens — to any authenticated stranger who knew a GUID. The group
carried `CanExecuteBlueprints`, which looks like authorization but resolves to a bare
`RequireAuthenticatedUser()`.
**Audit rule with teeth: a handler that takes an id and returns per-subject data, but does not take
`HttpContext`, cannot be checking ownership.** It has no caller identity to check against. That
signature alone convicted three endpoints here.
```csharp
// Tier gate on the route; ownership gate in the handler. You need BOTH.
var instance = await instanceStore.GetAsync(instanceId, ct);
if (instance is null) return Results.NotFound(...);
var blueprint = await blueprintStore.GetAsync(instance.BlueprintId); // BEFORE the decision
if (!await InstanceParticipantGate.IsParticipantAsync(httpContext, instance, walletClient, logger, ct)
&& !InstanceParticipantGate.IsAwaitingOpenParticipant(instance, blueprint))
{
return Results.Problem("You are not a participant on this instance.", statusCode: 403);
}
```
Two traps make the obvious implementation wrong in **opposite** directions — this codebase has now hit
both, more than once each:
| Trap | Symptom | Rule |
|---|---|---|
| Reading `wallet_address` off the claim set | 403s every real citizen (consumer tokens omit it, FR-014) **while leaving platform-tier callers unrestricted** | Always resolve via `ParticipantWalletResolver.ResolveUserWalletAddressesAsync` — claim fast path, then Wallet-Service lookup by owner. Match against the caller's FULL wallet set. |
| Gating on recorded participation alone | 403s the Feature 103 open participant on their own freshly-created instance — `CreateInstance` seeds `ParticipantWallets` only from participants that already carry a wallet, so the walk-in citizen is absent until their first submission seals | Add an explicit empty-shell carve-out (`CompletedActionCount == 0` **and** a current action whose sender is unbound). It closes the instant real data exists. |
Ordering matters too: resolve the blueprint **before** the gate decides, and raise any
"blueprint/action not found" error **after** it, so a non-participant cannot read the difference
between error bodies to probe instance internals (#1183).
An empty resolved wallet set means "could not resolve", not "any wallet" — **fail closed**.
Canonical implementations: `Sorcha.Blueprint.Service.Services.Infrastructure.InstanceParticipantGate`
and its four callers (`InstanceReadEndpoints`, `InstanceActionEndpoints`).
### Extract Claims in Handler
**When:** Need user/org context in endpoint logic.
```csharp
async Task<IResult> HandleRequest(ClaimsPrincipal user, ...)
{
var userId = user.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
var orgId = user.FindFirst("org_id")?.Value;
if (string.IsNullOrEmpty(orgId))
return Results.Forbid();
// Use orgId for data isolation
}
```
## See Also
- [patterns](references/patterns.md) - Token generation, validation, policies
- [workflows](references/workflows.md) - Setup, testing, troubleshooting
## Related Skills
- See the **minimal-apis** skill for endpoint configuration with `.RequireAuthorization()`
- See the **aspire** skill for shared configuration via `ServiceDefaults`
- See the **redis** skill for token revocation tracking
- See the **yarp** skill for gateway-level authentication
## Documentation Resources
> Fetch latest JWT/authentication documentation with Context7.
**How to use Context7:**
1. Use `mcp__context7__resolve-library-id` to search for "asp.net core authentication jwt"
2. **Prefer website documentation** (IDs starting with `/websites/`) over source code repositories
3. Query with `mcp__context7__query-docs` using the resolved library ID
**Recommended Queries:**
- "JWT Bearer authentication setup"
- "authorization policies claims"
- "token validation parameters"More agent context in Sorcha-Platform/Sorcha
79 other files this repository gives its agents, the first 60 shown.
AGENTS.md
CLAUDE.md
Copilot instructions
llms.txt
Skill
- aspire.claude/skills/aspire/SKILL.md
- aspnet-core.claude/skills/aspnet-core/SKILL.md
- blazor.claude/skills/blazor/SKILL.md
- blueprint-builder.claude/skills/blueprint-builder/SKILL.md
- configuring-opentelemetry-dotnet.claude/skills/configuring-opentelemetry-dotnet/SKILL.md
- coverage-analysis.claude/skills/coverage-analysis/SKILL.md
- coverlet.claude/skills/coverlet/SKILL.md
- cryptography.claude/skills/cryptography/SKILL.md
- database-expert.claude/skills/database-expert/SKILL.md
- docker.claude/skills/docker/SKILL.md
- dotnet.claude/skills/dotnet/SKILL.md
- entity-framework-core.claude/skills/entity-framework-core/SKILL.md
- entity-framework.claude/skills/entity-framework/SKILL.md
- fluent-assertions.claude/skills/fluent-assertions/SKILL.md
- frontend-design.claude/skills/frontend-design/SKILL.md
- grpc.claude/skills/grpc/SKILL.md
- mcp.claude/skills/mcp/SKILL.md
- microsoft-extensions.claude/skills/microsoft-extensions/SKILL.md
- migrate-xunit-to-xunit-v3.claude/skills/migrate-xunit-to-xunit-v3/SKILL.md
- minimal-api-file-upload.claude/skills/minimal-api-file-upload/SKILL.md
- minimal-apis.claude/skills/minimal-apis/SKILL.md
- mongodb.claude/skills/mongodb/SKILL.md
- moq.claude/skills/moq/SKILL.md
- nbitcoin.claude/skills/nbitcoin/SKILL.md
- network-bootstrap.claude/skills/network-bootstrap/SKILL.md
- nunit.claude/skills/nunit/SKILL.md
- optimizing-ef-core-queries.claude/skills/optimizing-ef-core-queries/SKILL.md
- playwright.claude/skills/playwright/SKILL.md
- postgresql.claude/skills/postgresql/SKILL.md
- prodexec.claude/skills/prodexec/SKILL.md
- redis.claude/skills/redis/SKILL.md
- scalar.claude/skills/scalar/SKILL.md
- signalr.claude/skills/signalr/SKILL.md
- sorcha-app.claude/skills/sorcha-app/SKILL.md
- sorcha-architecture.claude/skills/sorcha-architecture/SKILL.md
- sorcha-cli.claude/skills/sorcha-cli/SKILL.md
- sorcha-ui.claude/skills/sorcha-ui/SKILL.md
- speckit-agent-context-update.claude/skills/speckit-agent-context-update/SKILL.md
- speckit-analyze.claude/skills/speckit-analyze/SKILL.md
- speckit-checklist.claude/skills/speckit-checklist/SKILL.md
- speckit-clarify.claude/skills/speckit-clarify/SKILL.md
- speckit-constitution.claude/skills/speckit-constitution/SKILL.md
- speckit-git-commit.claude/skills/speckit-git-commit/SKILL.md
- speckit-git-feature.claude/skills/speckit-git-feature/SKILL.md
- speckit-git-initialize.claude/skills/speckit-git-initialize/SKILL.md
- speckit-git-remote.claude/skills/speckit-git-remote/SKILL.md
- speckit-git-validate.claude/skills/speckit-git-validate/SKILL.md
- speckit-implement.claude/skills/speckit-implement/SKILL.md
- speckit-plan.claude/skills/speckit-plan/SKILL.md
- speckit-specify.claude/skills/speckit-specify/SKILL.md
- speckit-tasks.claude/skills/speckit-tasks/SKILL.md
- speckit-taskstoissues.claude/skills/speckit-taskstoissues/SKILL.md
- verifiable-credentials.claude/skills/verifiable-credentials/SKILL.md
- walkthrough-builder.claude/skills/walkthrough-builder/SKILL.md
- worker-services.claude/skills/worker-services/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool public_context_discussion, action report. How to connect one.

