Tasks: Auth (JWT, Sessions, OAuth, API Keys)
Feature ID: auth-jwt-oauth
Status: Retrospective — most tasks reflect the as-built state.
Outstanding follow-ups are flagged.
Phase 1 — Module skeleton (DONE)
- T1:
AuthModuledeclared withDatabaseModule,HttpModule,ActivityLogModuleimports;controllers: [OAuthController, AuthController, ApiKeysController]; exportsAuthService,ApiKeyService,AuthSessionGuard,AUTH_PROVIDER,AUTH_RUNTIME_INSTANCE,AuthSyncService. - T2:
AUTH_PROVIDERprovided asuseExisting: AuthProviderService;AUTH_RUNTIME_INSTANCEprovided as a DataSource-injected factory. - T3:
@Public()decorator +IS_PUBLIC_KEYreflector token defined underauth/decorators/public.decorator.ts. - T4:
@CurrentUser()parameter decorator defined underauth/decorators/user.decorator.ts.
Phase 2 — Email + password (DONE)
- T5:
AuthController.register→assertCanRegister→signUpEmail→sendVerificationEmail(best-effort). Verified by FR-10, FR-11, FR-32. - T6:
AuthController.login→signInEmail→ fire-and-forgetuser.loginactivity row withipAddress/userAgent. - T7:
AuthService.sendVerificationEmail/verifyEmail/forgotPassword/getUserByPasswordResetToken/consumePasswordResetToken— all five flows pinned by FR-11 through FR-14. - T8:
AuthService.validateEmailVerificationTokenandvalidatePasswordResetTokenreturn{valid, message, email?, expiresAt?}shape (no throwing); used by the web client to gate the reset/verify form before submit. - T9:
AuthController.resetPasswordruns the full chaingetUserByPasswordResetToken→setPassword→consumePasswordResetToken→signOutAll.
Phase 3 — Sessions / runtime (DONE)
- T10:
AuthProviderService.signInEmail/signUpEmail/issueSession/signOut/signOutAll/setPassword/changePasswordcovered by FR-23 → FR-29. - T11:
AuthProviderService.authenticatechecks bearer token inauth_sessionfirst, falls back to Better Auth cookie session, refuses onisActive === false. - T12: 7-day TTL for
auth_session.expiresAtpinned increateSessionRecord(FR-25). - T13:
AuthSyncService.ensureCredentialAccount/getCredentialPasswordHash/syncCredentialPasswordround-trip between Better Auth credential rows andusers.password.
Phase 4 — OAuth (DONE)
- T14:
OAuthController.getAuthUrlreturns{url}for:providerId;authRedirectexchanges code, callsvalidateSocialUser, issues a session, fire-and-forgets the activity log. - T15:
SocialAuthService.getAuthorizationUrlbuilds the provider URL withclient_id,redirect_uri,response_type,scope, optionalstate, and Google-onlyaccess_type=offline+prompt=consent(FR-18). - T16:
SocialAuthService.authenticateexchanges the code (omitsgrant_typefor GitHub) and callsauthService.validateSocialUserwith the merged payload. - T17: All four provider readers
(
getGitHubUser/getGoogleUser/getFacebookUser/getLinkedInUser) implement the per-provider displayName fallback chain andemailVerifiedrules from FR-21. - T18:
SOCIAL_AUTH_PROVIDERSmap declares GitHub / Google / Facebook / LinkedIn with their authorization URLs, token URLs, scopes, callback resolvers, and (Facebook only)scopeSeparator: ','(FR-33). - T19:
getSocialAuthProviderConfigthrowsBadRequestException('Unsupported OAuth provider: <id>')for anything outside the four-provider set. - T20:
SocialAuthService.getConfiguredProvidersfilters byclientId() && clientSecret()so half-configured providers stay hidden fromGET /api/auth/providers. - T21:
AuthService.validateSocialUserreconciles new vs. existing users with the trusted-email rules from FR-15 (suspended user → 401; untrusted email + no existing link → 401; new user with trusted email → emitUserConfirmedEvent).
Phase 5 — API keys (DONE)
- T22:
ApiKeyService.createKeyenforces the 10-key cap and future-expiresAt validation, generates 32 bytes of entropy, prefixes withew_live_, stores SHA-256 digest + 12-char prefix, returns the raw key once (FR-8). - T23:
ApiKeyService.validateKeySHA-256 lookup,null-return on miss/expiry, fire-and-forgetupdateLastUsedwith.catch(() => {})(FR-9). - T24:
ApiKeyService.listKeys/revokeKey→ repository-levelfindByUserId/deleteByIdAndUserId. Controller throwsNotFoundExceptionon revoke-miss. - T25:
AuthSessionGuardextracts the API key fromx-api-keyORAuthorization: Bearer …(only when value starts withew_live_), validates, hydratesrequest.userwithiss='ever-works'/aud='ever-works'(FR-31).
Phase 6 — Tests (PARTIAL)
- T26:
auth.service.spec.ts— 45 tests covering every method in §2 ofspec.md. PR #486. - T27:
social-auth.service.spec.ts— 37 tests covering all four providers, all happy + error paths. PR #488. - T28:
api-key.service.spec.ts— 15 tests including 10-key cap, sha256 hashing, expiry semantics, fire-and-forget updateLastUsed swallow. PR #486. - T29:
github-email.utils.spec.ts— primary-email resolution from/user/emails. - T30 (follow-up):
auth.controller.spec.ts— 28 tests across all 14 endpoints. Pinned: activity-log fire-and-forget shape on login (req.ip→x-forwarded-forfallback,.catch(() => {})swallow),Failed to send verification emailwarn log on register-side mail failure with non-Error →String()coercion,verifyEmail→issueSessionchain (no session issue when verify rejects), reset-password 4-step ordering pinned via sharedorder: string[]array (getUserByPasswordResetToken→setPassword→consumePasswordResetToken→signOutAll),validateEmail*Tokennon-throwing return shape,registerassertCanRegister→signUpEmail→sendVerificationEmailordering,sendVerificationdeliberate omission of callback URL (unlikeregister). PR #597. - T31 (follow-up):
oauth.controller.spec.ts— 9 tests across the 2 endpoints. Pinned:getAuthUrlcallssocialAuthService.getAuthorizationUrl(providerId, undefined, state)with explicitundefinedforcallbackUrl;authRedirectrunssocialAuth.authenticate→authProvider.issueSession→activityLogService.logorder; activity-log payload containsaction='user.login.<providerId>',summary='Signed in via <displayName>',metadata={provider: providerId},ipAddress/userAgentderivation fromreq.ip || req.headers['x-forwarded-for']/req.headers['user-agent']; activity-log rejection is swallowed via.catch(() => {}); activity-log emission registered AFTERissueSessionresolves so it does NOT fire on session-store failure. PR #597. - T32 (follow-up):
api-keys.controller.spec.ts— 10 tests pinning all 3 endpoints.revokereturns{message: 'API key revoked successfully'}ontrue, throwsNotFoundException('API key not found')onfalse, propagates non-NotFound errors verbatim. The criticalrevokeKey(keyId, userId)positional invariant — NOT(userId, keyId)— pinned via dedicated test so a future refactor cannot accidentally let any user revoke any key by swapping args. PR #597. - T33 (follow-up):
auth-session.guard.spec.ts— 19 tests pinning every branch of the four-mode guard surface:@Public()short-circuit;x-api-keyheader withew_live_*prefix (rejects array headers, falls through on non-string / non-prefixed values);Authorization: Bearer ew_live_*(falls through on non-ew_live_Bearer tokens, falls through on lowercasebearerbecause scheme matching is case-sensitive); precedence (x-api-keywins overAuthorizationwhen both set); successful API-key path constructs the documentedAuthenticatedUserenvelope w/iss:'ever-works'/aud:'ever-works'/iat=now, falsy-avatarcoerced tonull, truthy-avatarpreserved verbatim;UnauthorizedException('Invalid or expired API key')on nullvalidateKey,UnauthorizedException('User account is inactive')for missing-user AND inactive-user; lazyModuleRef.get(ApiKeyService, {strict:false})+ModuleRef.get(UserRepository, {strict:false})resolution that fires only on the FIRST API-key request and is cached on the guard instance for subsequent requests; AuthProvider fallback (returns true + attaches provider user with the originalcookieheader propagated throughtoHeaders(), throws onnullprovider user, propagates errors verbatim, treats missingrequest.headersas empty without crashing). PR #597. - T34 (follow-up):
auth-provider.service.spec.ts— 39 tests. Pinned: bearer-token path (auth_sessionlookup, expiry deletes the row, hydrate viaassertActiveUser); Better Auth cookie path (suspended user →signOutAll+ 401,isActive: undefinedtreated as active, only strict-falsetrips suspended); falsy avatar / image coerced tonull;registrationProviderfallback to'local';signInEmailordering (ensureCredentialAccountBEFOREauth.api.signInEmailvia sharedorder: string[]array, password mirror skipped on social-only / non-existent users), post-sign-in update writespassword+lastLoginAt+registrationProvider:'local','Failed to establish authenticated session'401 when Better Auth returns no token, suspended user viaassertActiveUserAFTER Better Auth call;signUpEmailtoken-vs-no-token branches (token → direct envelope; no token → falls through toissueSession), post-sign-up update setsisActive: true+registrationProvider: 'local';issueSession7-day TTL (within 60s ofnow + 7 * 24 * 60 * 60 * 1000),ipAddress/userAgentinitialised to null, fresh token per call;changePasswordno-credential rejection, bcrypt mismatch, success path writes new hash to BOTH stores;setPasswordhashes via runtimepassword.hashthen writes both stores;signOutbearer-vs-cookie branching (deletes session row when bearer present, delegates to Better Auth otherwise);signOutAlldeletes every session row for the user;getBearerTokenprivate branches viaauthenticate(case-insensitivebearer/Bearerscheme matching, non-bearer schemes fall through, empty token after split → null, missing-Authorization → null). 4 tests onrequest-headers.spec.tscover thetoHeadershelper used by every controller call site (Headers passthrough w/ defensive copy, plain-object → Headers conversion, undefined → empty Headers, falsy values skipped via!valueguard, string-array values joined w/", ", lowercase normalisation by Headers API). PR-pending. - T35 (follow-up):
auth-sync.service.spec.ts— 12 tests. Pinned:findCredentialAccountquery shape{ userId, providerId: 'credential' };ensureCredentialAccountshort-circuits WITHOUT writing when an existing row is present; new-row create usesuserIdfor BOTHuserIdANDaccountIdfields with a freshrandomUUID()per call;syncCredentialPasswordfalls through toensureCredentialAccountwhen no row exists, callsrepository.update(id, { password })ONLY (no other columns touched) when one does;getCredentialPasswordHashreturns the hash on hit, null on missing row, null on null/empty-string password (theaccount?.password || nullcollapse). PR-pending. - T36 (follow-up):
apps/web/e2e/auth.spec.ts— audit against §2.1 / §2.2 ofspec.md. Confirm every scenario has a matching playwright case; add what's missing (notably the OAuth callback flow with route-mocked GitHub).
Phase 7 — Hardening (FOLLOW-UPS / out-of-scope deltas)
- T37 (OQ-2): Verify OAuth
stateon callback. Persist a short-TTL Redis key on URL request keyed bystate; verify and delete on callback; throwBadRequestException('Invalid OAuth state')on miss. - T38 (OQ-3): Gate the
verificationToken/resetTokenecho behindNODE_ENV !== 'production'. The shape becomes{message, expiresAt}in prod, drop-in compatible with the web client (which already prefers the email link). - T39 (OQ-1): Document the
isssplit between'ever-works'(API key) and'auth-runtime'(session) in theAuthenticatedUserJSDoc andauth/types/auth.types.ts. - T40 (OQ-5): Investigate whether
signOutAllshould also call into Better Auth's session-purge API to terminate cookie-bound sessions. Likely a 5-line change; needs runtime familiarity to confirm the right API. - T41: Per-key scopes for API keys (read-only / write /
admin). Out of scope for this spec; track in
apps/api/src/auth/services/api-key.service.tsas a future schema migration.
Outstanding bugs / hazards observed during write-up
- OBS-1 (T36): The OAuth callback path silently issues a session
even when the
statequery param is unset. Combined with OQ-2 this is exploitable; fix per T37. - OBS-2 (FR-11(f) / OQ-3):
sendVerificationEmailechoes the raw verification token in the API response. Same forforgotPassword. Convenient in dev, hazardous in prod logs. - OBS-3 (T34):
AuthProviderService.signOutAlldoes not call Better Auth's runtime session-purge API; cookie sessions can outlive a "log out everywhere" call until cookie expiry. - OBS-4 (R-3):
updateLastUsedwrites on every API-key request. Under load this becomes a hot row; debounce if profiling shows contention.