The service issues its own JWTs. OAuth providers (Google, GitHub) are used only as identity sources; the access token returned to the frontend is always minted internally.
Two parallel JWT flows live side-by-side:
- User JWT — issued after OAuth callback or a password login. Bearer scheme, signed with HMAC using
OAuth:Jwt:Secret(src/Infrastructure/Jwt/JwtTokenService.cs). Default lifetimeOAuth:Jwt:AccessTokenExpirationMinutes(60 inappsettings.json). - M2M JWT — issued via client-credentials at
POST /api/auth/m2m/tokenfor trusted automation. Same signing key; carries scope claims drawn fromM2mClientScope.
Refresh tokens (Domain.Entities.Auth.RefreshToken) are persisted and rotated on use. Default validity OAuth:Jwt:RefreshTokenExpirationDays (30).
Web.Api.Setup.Auth.SetupAuthentication checks whether OAuth:Jwt:Secret is set to a non-placeholder value:
- If set, the API self-validates JWTs (
ValidateIssuer,ValidateAudience,ValidateLifetime,ClockSkew=Zero). - If not set, it falls back to
IdentityServer:Authority/Audiencevalidation. Production runs the first branch; the placeholder string isYOUR_JWT_SECRET_KEY_MIN_32_CHARACTERS_LONG.
AuthController exposes:
GET /api/auth/googleandGET /api/auth/github— generate CSRF state, store it in the session along with the optionalfrontendReturnUrl(validated againstFrontend:AllowedRedirectOrigins), and redirect to the provider.GET /api/auth/google/callbackandGET /api/auth/github/callback— verify state, exchangecode, materialise or look up the user, then redirect to the frontend withaccess_token,refresh_token,expires_inquery parameters.
The provider clients live in src/Infrastructure/Authentication/OAuth/ and are selected by OAuthProviderFactory.
User carries password hash, password-reset token (with expiration), email-verification token (with expiration), failed-login counter, and a lockout window. LoginHandler and RegisterHandler enforce:
- BCrypt password hashing (
PasswordHasher, packageBCrypt.Net-Next). - Account lockout after 5 failed attempts for 15 minutes.
- Anti-bot rules: a hidden
Websitehoneypot field must be empty;FormDurationSecondsmust be ≥ 1 second on login and ≥ 2 seconds on register. Failures look identical to invalid credentials — they throwUnauthorizedException(login) orBadRequestException(register) rather than a distinct error. Seechangelog.md2026-03-28 for rationale.
Email verification is required before login; the verification link points to GET /api/auth/verify-email/{token} which then redirects to Frontend:BaseUrl/login?verified=true.
Two custom filter attributes guard endpoints:
[HasAnyRole(params Role[])](src/Web.Api/Setup/Attributes/HasAnyRoleAttribute.cs) — requires at least one claim and, when roles are passed, at least one matching role.Roleis a[Flags]-style enum ofUndefined,Interviewer,Admin. Failing requests throwAuthenticationException(no claims) orNoPermissionsException(claims, wrong role) — both are mapped to HTTP responses byExceptionHttpMiddleware.[RequiresScope(...)](src/Web.Api/Setup/Attributes/RequiresScopeAttribute.cs) — used on M2M endpoints; checks the scope claim against the route's required scope.
IAuthorization (src/Infrastructure/Authentication/AuthorizationService.cs) is the runtime façade. GetCurrentUserOrFailAsync() looks the user up by email or IdentityId, creates one on first sight, refreshes their LastLoginAt, and caches the result for the request.
OAuth state is stored in ASP.NET Core's session middleware. Cookies are configured HttpOnly, SameSite=None, SecurePolicy=Always, idle timeout 30 minutes (Startup.ConfigureServices). The frontend redirect target is validated against Frontend:AllowedRedirectOrigins to prevent open-redirect after callback.
[AllowAnonymous] on auth endpoints, sitemap, RSS feeds, public surveys list, public salary endpoints. Everything else falls back to JWT bearer.
Two well-known endpoints describe the auth surface so AI agents can discover how to authenticate without hard-coding URLs:
| Endpoint | Spec | Served by |
|---|---|---|
https://api.techinterview.space/.well-known/oauth-protected-resource |
RFC 9728 | Features/WellKnown/WellKnownController.cs (this repo). Lists the resource URL, the authorization server (OAuth:Jwt:Issuer), and bearer_methods_supported = ["header"]. |
https://techinterview.space/.well-known/oauth-authorization-server |
RFC 8414 | Frontend Express SSR (frontend/src/server/well-known.ts). Lives there because the JWT iss claim is the frontend origin. Advertises the token endpoint (/api/auth/m2m/token), grant_types_supported = ["client_credentials"], and token_endpoint_auth_methods_supported = ["client_secret_post"]. |
The site is not a general-purpose authorization server: user-facing flows delegate to Google/GitHub (no local authorization_endpoint), tokens are HMAC-signed (no public jwks_uri), and the M2M endpoint accepts a non-standard scopes array rather than the RFC 6749 space-delimited scope parameter. The metadata documents only what is actually exposed.
| Key | Purpose |
|---|---|
OAuth:Google:ClientId / ClientSecret / RedirectUri |
Google OAuth |
OAuth:GitHub:ClientId / ClientSecret / RedirectUri |
GitHub OAuth |
OAuth:Jwt:Secret |
HMAC signing key for internal JWTs |
OAuth:Jwt:Issuer / Audience |
Token validation parameters |
OAuth:Jwt:AccessTokenExpirationMinutes / M2mTokenExpirationMinutes / RefreshTokenExpirationDays |
Lifetimes |
Frontend:BaseUrl / CallbackUrl / AllowedRedirectOrigins |
Post-OAuth redirect destinations |
IdentityServer:Authority / Audience |
Fallback when OAuth:Jwt:Secret is the placeholder |
In production the sed substitution in .github/workflows/deploy.yml writes secrets directly into appsettings.Production.json at build time. See docs/gotchas.md.