Module: auth¶
Purpose¶
Manages user identity: JWT issuance and validation, OAuth2 (GitHub + Google), SSO/OIDC via dynamic DB-backed client registration, API key authentication, refresh token rotation, and role-based access control.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
User |
users |
email (UNIQUE), displayName, avatarUrl, passwordHash (nullable), oauthProvider, oauthSubject, systemRole: SystemRole, orgId: UUID? |
RefreshToken |
refresh_tokens |
tokenHash (SHA-256 of the JWT string, UNIQUE), userId (FK→users), expiresAt, revoked: Boolean |
ApiKey |
api_keys |
name, keyHash (SHA-256 of tw_… plaintext, UNIQUE), keyPrefix (first 12 chars of plaintext), projectId (FK→projects, nullable), createdBy (FK→users), lastUsedAt, expiresAt |
SsoConfig |
sso_configs |
name, issuerUrl, clientId, clientSecretEnc (AES-GCM encrypted), enabled, autoProvision |
SystemRole is an enum with values ADMIN and MEMBER. The first user to register is automatically promoted to ADMIN.
DB Schema¶
users (V1)¶
CREATE TABLE users (
id UUID NOT NULL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
avatar_url VARCHAR(512),
password_hash VARCHAR(255),
oauth_provider VARCHAR(50),
oauth_subject VARCHAR(255),
system_role VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Index: idx_users_email on email.
refresh_tokens (V6)¶
| Column | Type | Constraint |
|---|---|---|
id |
UUID | PK |
token_hash |
VARCHAR(64) | UNIQUE NOT NULL |
user_id |
UUID | FK→users ON DELETE CASCADE |
expires_at |
TIMESTAMP | NOT NULL |
revoked |
BOOLEAN | NOT NULL DEFAULT FALSE |
Index: idx_refresh_tokens_user on user_id.
api_keys (V13)¶
| Column | Type | Constraint |
|---|---|---|
id |
UUID | PK |
name |
VARCHAR(255) | NOT NULL |
key_hash |
VARCHAR(64) | UNIQUE NOT NULL |
key_prefix |
VARCHAR(12) | NOT NULL |
project_id |
UUID | FK→projects ON DELETE CASCADE, nullable |
created_by |
UUID | FK→users NOT NULL |
last_used_at |
TIMESTAMPTZ | nullable |
expires_at |
TIMESTAMPTZ | nullable |
Indexes: idx_api_keys_project, idx_api_keys_hash.
sso_configs (V18)¶
| Column | Type | Notes |
|---|---|---|
id |
UUID | PK |
name |
VARCHAR(100) | NOT NULL |
issuer_url |
VARCHAR(500) | NOT NULL |
client_id |
VARCHAR(255) | NOT NULL |
client_secret_enc |
VARCHAR(500) | AES-GCM encrypted with key derived from TW_JWT_SECRET |
enabled |
BOOLEAN | DEFAULT true |
auto_provision |
BOOLEAN | DEFAULT true — creates user on first OIDC login |
API Endpoints¶
AuthController — /api/v1/auth¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/register |
PUBLIC | Creates a new user account; first user becomes ADMIN |
| POST | /api/v1/auth/login |
PUBLIC | Validates password, returns access + refresh tokens |
| POST | /api/v1/auth/refresh |
PUBLIC | Rotates refresh token; returns new token pair |
| POST | /api/v1/auth/logout |
USER | Revokes all refresh tokens for the authenticated user |
| GET | /api/v1/auth/me |
USER | Returns UserResponse for the authenticated principal |
| POST | /api/v1/auth/switch-org/{orgId} |
USER | Issues an access token scoped to the target org |
ApiKeyController — /api/v1/projects/{key}/api-keys¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/projects/{key}/api-keys |
USER | Lists API keys for a project (no plaintext) |
| POST | /api/v1/projects/{key}/api-keys |
USER | Generates a new API key; returns plaintext once |
| DELETE | /api/v1/projects/{key}/api-keys/{keyId} |
USER | Revokes (deletes) an API key; requires project admin |
SsoController — /api/v1/admin/sso¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/v1/admin/sso/public |
PUBLIC | Lists enabled SSO configs (id + name only, no secrets) |
| GET | /api/v1/admin/sso |
ADMIN | Lists all SSO configs with full details |
| POST | /api/v1/admin/sso |
ADMIN | Creates a new SSO/OIDC provider config |
| DELETE | /api/v1/admin/sso/{id} |
ADMIN | Deletes an SSO config |
Events Emitted¶
None. Auth changes (registration, login, logout) are not published as domain events. Security-relevant actions are recorded directly via SecurityAuditListener (in the audit module).
Events Consumed¶
None. No @EventListener annotations exist in auth/application/.
Key Files¶
| File | Responsibility |
|---|---|
backend/src/main/kotlin/com/taskowolf/auth/domain/User.kt |
@Entity for users; extends AuditableEntity |
backend/src/main/kotlin/com/taskowolf/auth/domain/RefreshToken.kt |
@Entity for refresh_tokens; stores SHA-256 hash, not plaintext |
backend/src/main/kotlin/com/taskowolf/auth/domain/ApiKey.kt |
@Entity for api_keys; stores SHA-256 hash and tw_-prefixed 12-char prefix |
backend/src/main/kotlin/com/taskowolf/auth/domain/SsoConfig.kt |
@Entity for sso_configs; clientSecretEnc is AES-GCM encrypted |
backend/src/main/kotlin/com/taskowolf/auth/domain/SystemRole.kt |
Enum { ADMIN, MEMBER }; mapped as VARCHAR in DB |
backend/src/main/kotlin/com/taskowolf/auth/application/JwtService.kt |
Generates and validates HS256 JWTs; enforces type claim (access/refresh) to prevent token-type confusion; @PostConstruct validates secret is ≥ 32 bytes |
backend/src/main/kotlin/com/taskowolf/auth/application/AuthService.kt |
Register, login, refresh, logout; first user promoted to ADMIN; registration can be disabled via taskowolf.auth.registration-enabled |
backend/src/main/kotlin/com/taskowolf/auth/application/RefreshTokenService.kt |
Rotation: each consumed token is immediately revoked; stores SHA-256 hash of the JWT string |
backend/src/main/kotlin/com/taskowolf/auth/application/ApiKeyService.kt |
Generates tw_-prefixed tokens (24 random bytes, URL-safe base64); stores only SHA-256 hash; authenticate() is called per-request by ApiKeyAuthFilter |
backend/src/main/kotlin/com/taskowolf/auth/application/SsoService.kt |
AES-GCM encrypt/decrypt for OIDC client secrets; encryption key is SHA-256 of TW_JWT_SECRET |
backend/src/main/kotlin/com/taskowolf/auth/application/OidcUserProvisioningService.kt |
Creates User on first OIDC login when autoProvision=true; auto-assigns to default org |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/SecurityConfig.kt |
Spring Security filter chain; stateless JWT sessions; @EnableMethodSecurity for @PreAuthorize; routes oauth2Login through DbClientRegistrationRepository |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/JwtAuthFilter.kt |
Extracts Bearer <token> header, validates with JwtService, populates SecurityContext with User principal and ROLE_<systemRole> authority |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/ApiKeyAuthFilter.kt |
Runs before JwtAuthFilter; detects Bearer tw_ prefix; delegates to ApiKeyService.authenticate() |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/AuthRateLimitFilter.kt |
IP-based sliding-window rate limiter applied to /api/v1/auth/login and /api/v1/auth/register; returns 429 on breach |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/JwtStompInterceptor.kt |
Validates JWT in STOMP CONNECT frame Authorization header; sets StompHeaderAccessor.user so WebSocket subscriptions are authenticated |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/DbClientRegistrationRepository.kt |
Implements ClientRegistrationRepository by reading live sso_configs rows; enables runtime OIDC provider registration without application restart |
backend/src/main/kotlin/com/taskowolf/auth/infrastructure/RateLimiter.kt |
In-memory ConcurrentHashMap sliding-window counter; configurable via taskowolf.auth.rate-limit.max-requests (default 10) and taskowolf.auth.rate-limit.window-seconds (default 60) |
Extension Points¶
Adding a new OIDC/OAuth2 provider at runtime:
Insert a row into sso_configs or call POST /api/v1/admin/sso. DbClientRegistrationRepository.findByRegistrationId() reads the DB on every OAuth2 authorization request — no code change or restart is needed.
// DbClientRegistrationRepository.kt — how dynamic registration works
override fun findByRegistrationId(registrationId: String): ClientRegistration? {
val config = ssoService.listEnabled().find { it.id.toString() == registrationId }
?: return null
return ClientRegistrations.fromOidcIssuerLocation(config.issuerUrl)
.registrationId(registrationId)
.clientId(config.clientId)
.clientSecret(ssoService.decryptSecret(config.clientSecretEnc))
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email")
.build()
}
Adding a new role:
- Add the new value to
SystemRoleenum inbackend/src/main/kotlin/com/taskowolf/auth/domain/SystemRole.kt. - Update
JwtAuthFilterandApiKeyAuthFilterif the authority string construction needs to change. - Add
@PreAuthorize("hasRole('NEW_ROLE')")to the relevant controller methods. - Update
SecurityConfig.authorizeHttpRequestsif a URL-level check is required.
Common Pitfalls¶
- DO NOT store API key tokens unhashed. Only
keyHash(SHA-256 of thetw_…plaintext) is persisted. The plaintext is returned once inCreateApiKeyResponse.plaintextand never stored. - DO NOT inject
UserRepositoryfrom outside theauthmodule. Other modules must use theSecurityContextprincipal (@AuthenticationPrincipal User) or an event/service boundary to access user data. - DO NOT add
permitAll()entries toSecurityConfigwithout a security review. Each new public route increases the attack surface. - Webhook secrets (used for HMAC validation in the
integrationsmodule) are stored in plaintext intentionally — HMAC requires the raw value. Do not apply the API-key SHA-256 hashing pattern there.
Example¶
API key generation endpoint — plaintext is returned once and never re-derivable:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable key: String,
@Valid @RequestBody request: CreateApiKeyRequest,
@AuthenticationPrincipal user: User
) = apiKeyService.generate(key, request.name, request.expiresAt, user)
// In ApiKeyService.generate():
val plaintext = "tw_" + secureToken() // 24 random bytes, base64url
val hash = sha256(plaintext) // SHA-256 hex string
val prefix = plaintext.take(12) // stored for display only
val savedKey = apiKeyRepository.save(
ApiKey(name = name, keyHash = hash, keyPrefix = prefix,
createdBy = user.id, expiresAt = expiresAt)
// projectId and lastUsedAt use their nullable defaults
)
return CreateApiKeyResponse(savedKey.id, savedKey.name, savedKey.keyPrefix, plaintext)
The caller receives the full plaintext value once. All subsequent authentication uses sha256(incomingToken) compared against keyHash.
Test Patterns¶
Unit tests (MockK, no Spring context)¶
| File | What is tested |
|---|---|
AuthServiceTest |
Registration conflict, token pair generation, disabled registration, first-user ADMIN promotion, refresh token rejection |
JwtServiceTest |
@PostConstruct validation rejects blank and short secrets with actionable error messages |
RefreshTokenServiceTest |
Rotation: valid token is revoked on consume; unknown, revoked, and expired tokens are rejected |
ApiKeyServiceTest |
generate stores SHA-256 hash not plaintext; authenticate resolves user for valid token, returns null for non-tw_ tokens, expired keys, and unknown hashes |
SsoServiceTest |
AES-GCM encrypt/decrypt round-trip; createConfig persists encrypted (not plaintext) secret |
RateLimiterTest |
Sliding window: allows up to limit, rejects beyond limit, independent per IP, resets correctly |
Integration tests (Spring Boot Test + MockMvc + real DB, extend IntegrationTestBase)¶
| File | What is tested |
|---|---|
AuthControllerIntegrationTest |
Full register → login flow; duplicate email returns 409 |
ApiKeyControllerTest |
Create key returns tw_-prefixed plaintext once; list returns keys without plaintext; delete removes key; API key token authenticates subsequent requests |