6.7 KiB
Step 4 Output: Authentication & Authorization
Summary
Implemented all three authentication schemes, authorization policies, role-based access control, and the full auth/API key endpoint suite.
What Was Built
Authentication Schemes
EnvironmentKeyAuthenticationHandler (MicCheck.Api/Authentication/)
- Reads
X-Environment-Keyheader - Looks up
EnvironmentbyApiKeyin the database - Sets
EnvironmentIdandProjectIdclaims on success - Scheme name constant:
EnvironmentKeyAuthenticationHandler.SchemeName
ApiKeyAuthenticationHandler (MicCheck.Api/Authentication/)
- Parses
Authorization: Api-Key <TOKEN>header - Hashes the raw token with SHA-256 and looks it up in
ApiKeystable - Validates
IsActiveandExpiresAt - Sets
OrganizationIdandOrganizationRole = Adminclaims on success (API keys grant org-admin access)
JWT Bearer — existing TokenService updated to accept User (not a username string), now embeds sub (userId), email, given_name, family_name, plus OrganizationId and OrganizationRole claims for each org membership.
Authorization Policies (MicCheck.Api/Authorization/)
| Policy | Schemes | Requirement |
|---|---|---|
FlagsApiAccess |
EnvironmentKey | EnvironmentId claim present |
AdminApiAccess |
ApiKey, Bearer | Authenticated user |
OrganizationAdmin |
ApiKey, Bearer | OrganizationRole = Admin claim |
RBAC (MicCheck.Api/Authorization/)
ProjectPermissionenum (12 values: ViewProject, CreateFeature, EditFeature, etc.)UserProjectPermissionentity —(UserId, ProjectId)composite key,IsAdminflag,List<ProjectPermission>stored as comma-separated string via EF Core value converterProjectPermissionRequirement—IAuthorizationRequirementwrapping aProjectPermissionProjectPermissionRequirementHandler— resolves project ID from route values (projectId), bypasses check for org admins, checksUserProjectPermissionrecord otherwise
Auth Service & Endpoints (MicCheck.Api/Auth/)
AuthService — login, register, refresh, logout:
- Login: verifies password with
IPasswordHasher<User>, issues JWT + 30-day refresh token - Register: creates
User, defaultOrganization, joins asAdmin, issues tokens - Refresh: revokes old refresh token, issues new JWT + new refresh token (rotation)
- Logout: revokes refresh token
Endpoints at /api/v1/auth/ (all anonymous):
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/auth/login |
Returns LoginResponse(AccessToken, RefreshToken, ExpiresAt) |
| POST | /api/v1/auth/register |
Creates user + org, returns same |
| POST | /api/v1/auth/refresh |
Rotates refresh token |
| POST | /api/v1/auth/logout |
Revokes refresh token, returns 204 |
API Key Management (MicCheck.Api/ApiKeys/)
ApiKeyHasher — static utility:
GenerateKey()—RandomNumberGenerator.GetBytes(32)→ Base64URL (no+,/,=)Hash(key)— SHA-256 hex string (64 chars, lowercase)
ApiKeyService — create, list, revoke
Endpoints at /api/v1/organisations/{organizationId}/api-keys/ (require OrganizationAdmin):
| Method | Path | Description |
|---|---|---|
| POST | / |
Returns CreateApiKeyResponse including the raw key (shown once only) |
| GET | / |
Returns list with prefix only (hashed key never returned) |
| DELETE | /{keyId} |
Sets IsActive = false |
New Domain Types
RefreshToken(MicCheck.Api/Users/) — with EF Core cascade delete on userUserProjectPermission(MicCheck.Api/Authorization/)- New request/response records:
LoginRequest,LoginResponse,RegisterRequest,RefreshRequest,LogoutRequest,CreateApiKeyRequest,CreateApiKeyResponse,ApiKeyResponse
New EF Core Configurations
RefreshTokenConfiguration— unique index onToken, index onUserId, cascade deleteUserProjectPermissionConfiguration— composite key(UserId, ProjectId),Permissionsstored as comma-separated string
Deviations from Plan
UserProjectPermission.Permissionsdeclared asList<ProjectPermission>(notICollection) to ensure EF Core value converter works correctly- API keys granted
OrganizationRole = Adminclaim — necessary forOrganizationAdminpolicy (whichRequireClaim("OrganizationRole", "Admin")); without it, API-key-authenticated requests could never manage API keys
Tests Added
ApiKeyHasherTests (7 tests)
- Hash is deterministic
- Hash is lowercase hex (64 chars)
- Different inputs produce different hashes
- Generated key is non-empty and unique
- Generated key is Base64URL-safe (no
+,/,=) - Generated key can be verified by hashing
AuthEndpointsTests (10 tests)
End-to-end via WebApplicationFactory with in-memory DB:
- Register with valid details → 200 with tokens
- Register duplicate email → 409
- Login with correct credentials → 200 with tokens
- Login with wrong password → 401
- Login with unknown email → 401
- Login with empty email → 400
- Refresh with valid token → 200 with new tokens
- Refresh with invalid token → 401
- Refresh with already-used (revoked) token → 401
- Logout → 204, subsequent refresh → 401
ApiKeyAuthenticationHandlerTests (5 tests)
Via WebApplicationFactory against the API key management endpoints:
- Missing auth header → 401
- Bearer scheme (not Api-Key) → 401
- Invalid key → 401
- Inactive key → 401
- Expired key → 401
- Valid key → 200
ProjectPermissionRequirementHandlerTests (7 tests)
Direct unit tests with in-memory DB:
- Org admin bypasses all project checks → succeeds
- User with required permission → succeeds
- Project admin (
IsAdmin = true) satisfies any permission → succeeds - User without required permission → fails
- User with no permission record → fails
- Missing user ID claim → fails
- Missing project ID in route → fails
TokenServiceTests (5 tests, updated)
Updated for new User-based signature — verifies token non-empty, email claim, expiry, issuer, and org claims.
Issues Encountered
-
WebApplicationFactory+ multiple EF Core providers — RemovingDbContextOptions<MicCheckDbContext>alone does not remove Npgsql'sIDatabaseProvider. Fixed by usingUseInternalServiceProviderwith a dedicatedInMemoryEfServiceProvider(static), which tells EF Core to bypass the application DI container for provider discovery entirely. -
Per-request Guid DB name — Initially
Guid.NewGuid()was evaluated inside theAddDbContextoptions lambda, generating a new DB name perDbContextcreation. Each HTTP request therefore got an empty database. Fixed by capturing the Guid once inConfigureWebHostbefore theConfigureServiceslambda.
Test Results
Passed! - Failed: 0, Passed: 102, Skipped: 0, Total: 102