The API registers three authentication schemes side by side and a small role/claim authorization layer on top. There is no JWT anywhere in the codebase, despite what the (outdated) root README claims — verified by grepping the solution for JWT packages/usages.
| Scheme | Where configured | Used by | Typical caller |
|---|---|---|---|
ASP.NET Core Identity cookie (IdentityConstants.ApplicationScheme) |
services.AddIdentity<TUser,TRole>() (src/Core/Common/Startup/Startup{TUser,TRole}.cs:455-465) + cookie options in src/Presentation/Api/Startup.cs:150-182 |
Browser/web-app clients that call login |
First-party web frontend |
Custom opaque bearer token (TokenAuthenticationScheme) |
TokenAuthenticationHandler (src/Core/Common/Identity/TokenAuthenticationHandler.cs), registered src/Core/Common/Startup/Startup{TUser,TRole}.cs:346-350 |
Mobile/SPA/API clients that call tokens |
Non-browser API clients |
ApiKey scheme (ApiKeyAuthenticationScheme) |
ApiKeyAuthenticationHandler (src/Core/Common/Identity/ApiKey/ApiKeyAuthenticationHandler.cs), registered same block as above |
A handful of endpoints tagged [ApiKey] |
Trusted server-to-server callers holding the shared key |
- Standard
Microsoft.AspNetCore.Identitycookie auth (IdentityConstants.ApplicationScheme).POST /api/v1/identities/login(src/Presentation/Api/Controllers/IdentitiesController.cs:38-81) validates username/password viaIIdentityService.AuthenticateAsync, then callsSignInAsync, which issues the Identity cookie.GET /api/v1/identities/logout(IdentitiesController.cs:119-138) signs the user out. - Cookie hardening (
src/Presentation/Api/Startup.cs:150-182):HttpOnly,SameSite=None,Secure=Always,ExpireTimeSpanbound to the sameIdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespanconfig value the opaque token uses (10 days,appsettings.json:130). OnRedirectToLogin/OnRedirectToAccessDeniedevents are overridden to return 401/403 JSON-friendly responses (instead of the default redirect-to-login-page behavior) and set CORS headers manually for those two responses (Startup.cs:157-176) rather than letting the CORS middleware handle them — flagged internally as fragile and worth revisiting, not detailed further here.- Per-request re-validation.
OnValidatePrincipalcallsIIdentityService.ValidatePrincipalAsync(Startup.cs:177-181) on every single request carrying the cookie. This is driven bySecurityStampValidatorOptions.ValidationInterval = 00:00:00(appsettings.json:133-135, bound atStartup{TUser,TRole}.cs:464) — i.e. the interval is effectively zero, so the security stamp is checked against the DB on every request rather than cached for a window. Effect: revoking a user (password change, lockout, role change) takes effect immediately, at the cost of a DB round-trip per authenticated request. - Password/account/lockout policy is configured under
IdentityOptionsinappsettings.json— seedocs/business/identity-and-access.mdfor the business-level read (currently more permissive than recommended; exact values intentionally not enumerated here).
This is the scheme most non-browser API clients use.
Obtaining a token — POST /api/v1/identities/tokens
(src/Presentation/Api/Controllers/IdentitiesController.cs:161-207, [AllowAnonymous]):
- Body:
GenerateTokenRequestViewModel(Username,Password). - Controller calls
IIdentityService.AuthenticateAsync(local username/password check), thenIIdentityService.GenerateUserTokenAsync(src/Application/Service/IdentityService.cs:549-582). GenerateUserTokenAsynccalls ASP.NET Identity'sUserManager.GenerateUserTokenAsyncwithTokenProvider = "ApiDataProtectorTokenProvider"andPurpose = "ApiDataProtectorTokenProviderAccessToken"(constants insrc/Core/Common/Identity/PermissionConstants.cs:9-10), backed by a customApiDataProtectorTokenProvider<TUser>(src/Core/Common/Identity/ApiDataProtectorTokenProvider{TUser}.cs) registered againstIdentityOptions.Tokens.ProviderMap(Startup{TUser,TRole}.cs:350).- The raw provider token is then persisted via
UserManager.SetAuthenticationTokenAsyncunder the same provider/purpose, and the response token returned to the client is:— the delimiter is{userId}|{providerToken}Constants.DelimiterAlternate = "|"(src/Core/Common/Core/Constants.cs:17), concatenated atIdentityService.cs:568(ANALYZE.md's writeup describes this as{userId}:{token}; the literal separator in code is|, not:). - Response
GenerateTokenResponseViewModelcarriesTokenandExpirationTime(now +IdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespan, 10 days by default,appsettings.json:128-131).
Two alternate token-issuing endpoints exist, both [AllowAnonymous]:
POST /api/v1/identities/tokens/old— exchanges a legacy "core" token for a new one viaGenerateTokenByCoreTokenAsync(explicitly commented// this is temporary, must delete,IdentitiesController.cs:209-251). Requires the caller to already hold a legacy token; does not create a local user if none is found by email — see the legacy-auth-bridge below for the endpoint that replaces this one. Validates the incoming legacy JWT's signature via the sameCore:JwtSigningSecret-backed check the bridge uses (ValidateLegacyJwtAsync) — this used to skip signature validation entirely (a real forgeable-token gap, closed alongside the bridge work below).POST /api/v1/identities/tokens/google— exchanges a Google OAuth code/id-token for a token via the sameAuthenticateAsync+GenerateUserTokenAsyncpipeline, withAuthenticationProvider.Google.
Presenting a token — send Authorization: Bearer {userId}|{providerToken} (or a gama-api JWT,
see below) on any request. TokenAuthenticationHandler.HandleAuthenticateAsync
(src/Core/Common/Identity/TokenAuthenticationHandler.cs:30-61):
- Strips the
Bearerprefix. - Splits on
|into exactly 2 parts (userId,token). A gama-api JWT never contains|(it's base64url), so this reliably tells the two token shapes apart. - If it split into 2 parts, calls
ITokenService.VerifyTokenAsyncwith the same provider/purpose constants used to mint it (the normal path). Otherwise callsITokenService.VerifyLegacyTokenAsync— see the legacy-auth-bridge section below. - On success, builds a
ClaimsPrincipalfrom the returned claims and issues anAuthenticationTicketunder this scheme's name. - Any malformed header, unknown user, or failed verification yields
AuthenticateResult.NoResult()(notFail) — i.e. the request falls through as unauthenticated rather than erroring.
LegacyAuthBridgeController (src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs,
route api/v1/legacy-auth, [AllowAnonymous]) proxies gama-api's (the old PHP backend)
login/register/recovery/googleAuth endpoints so the frontend can migrate off gama-api one
flow at a time, while both backends stay usable during the transition. Slated for removal —
alongside tokens/old above — once the frontend fully migrates.
POST login/POST googleproxy gama-api's/users/login//users/googleAuth(ICoreProvider.LegacyLoginAsync/LegacyGoogleAuthAsync,src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs). On success (gama-api returnsjwtToken+info),IdentityService.SyncLegacyAuthAsyncdecodes and cryptographically verifies the legacy JWT (ValidateLegacyJwtAsync, shared with the two call sites below) to getCoreId/identity, finds the localApplicationUserbyCoreId→ email → phone (falling back rather than erroring, so a pre-existing native account gets linked, not duplicated), creates one via the normalUserManager.CreateAsyncpath if none matches — and hands gama-api'sjwtTokenstraight back to the frontend, unchanged. No gamatrain-back token is minted for this flow at all.loginOTP step-up (undocumented in gama-api's OpenAPI spec, found by live testing). For a weak/easy-to-guess password, gama-api doesn't return a token at all — it responds{"status":1,"data":{"type":"loginByOTP"}}and sends a fresh OTP to the identity, invalidating any previous one (every plainlogincall resends).LegacyLoginRequestDto/LegacyLoginRequestViewModelaccept optionalType/Codefields for this: the bridge relays{"type":"loginByOTP"}back to the frontend as a successful, actionable response (not an error —LegacyBridgeTokenResponseDto.Typeset,Tokennull), and the frontend resubmitsloginwithtype: "confirm"+ the receivedcode(identity/passstill required) to complete it, at which point gama-api returns the normaljwtToken+infoshape and sync/return proceeds as usual.
POST register/POST recoveryproxy gama-api's/users/register//users/recovery(ICoreProvider.LegacyRegisterAsync/LegacyRecoveryAsync) as pure passthroughs — no local user sync, no token minted. Both are multi-step OTP flows on gama-api's side (type:request/resend_code/confirm/final), and neither ever returns a token at any step ({"status":1,"data":{"message":"done"}}even on the final step) — the frontend callsloginafterward to actually get a session, which is where sync happens.login/google/register/recoveryforward the caller's real IP to gama-api asTRUSTED_FORWARDED_IP. Since this backend proxies the request, gama-api's own rate-limiting/fraud checks would otherwise only ever see this server's IP, never the end user's.IdentityServicereads the client IP off the inbound request (HttpContext.GetClientIpAddress()— trusts an existingX-Forwarded-Forheader if present, else the raw connection IP) and sets it onLegacyLoginRequestDto/LegacyGoogleAuthRequestDto/LegacyOtpFlowRequestDtobefore callingICoreProvider;CoreProvideradds it as aTRUSTED_FORWARDED_IPheader on the outgoing gama-api call (Constants.TrustedForwardedIp).logoutdoesn't send it — gama-api didn't ask for it there.GET logoutproxies gama-api'sGET /users/logout(ICoreProvider.LegacyLogoutAsync,Core:Logoutconfig) as a pure passthrough — same shape as register/recovery. The caller's raw legacy JWT is read straight from the incomingAuthorizationheader (TokenAuthenticationHandler.GetTokenFromHeader) and relayed unchanged as gama-api's ownbearerAuth; this backend never stored the token, so there's nothing local to update — gama-api is the one that actually invalidates the session server-side. This is the one legacy-bridge operation that does end a session early, unlike the trade-off described below fortokens/revoke.
Why no wrapping. The natural design would be to mint a gamatrain-back token and hand back some
combination of the two. Instead, gamatrain-back adapts to gama-api's token instead of the other way
around: ITokenService.VerifyLegacyTokenAsync (IdentityService.cs) validates an incoming gama-api
JWT directly and resolves it to the local user already linked by CoreId. The frontend ends up
holding exactly one token, identical to what it already gets from gama-api today, usable unchanged
against both backends. gama-api needs zero code changes, since it never sees anything but its
own token in its own format.
Signature verification is real, not skipped — this requires a shared secret. All three
JWT-accepting code paths (VerifyLegacyTokenAsync, SyncLegacyAuthAsync,
GenerateTokenByCoreTokenAsync/tokens/old) go through one shared helper,
IdentityService.ValidateLegacyJwtAsync, which checks issuer, audience, expiry, and the
token's HS256 signature against Core:JwtSigningSecret. Without real signature verification, anyone
could hand-craft a JSON object with the right issuer/audience/expiry/user_id claims and a garbage
signature and it would be accepted as genuine — a full account-takeover path for any user who's ever
been linked via CoreId. (Earlier revisions of this bridge, and the pre-existing tokens/old
endpoint before this change, skipped signature validation entirely — Core:JwtSigningSecret must be
the real HS256 key gama-api signs with, obtained from their team out-of-band; it is not
populated in the tracked appsettings.json, empty by default per the repo's "never commit a real
secret" rule, and every legacy-JWT code path fails closed — rejects the token — until it's set.)
Trade-offs of this approach (accepted deliberately, worth knowing if debugging a legacy-bridge session):
- Session lifetime is gama-api's, not ours. A legacy-bridge session lives until the JWT's own
expclaim (~30 days per observed samples), not the configurableIdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespanthat governs normal opaque-token sessions. tokens/revokecannot end a legacy-bridge session early. JWTs are self-contained/stateless — there is no server-side store here to invalidate. This only affects sessions started vialegacy-auth/login/google; native opaque-token sessions revoke exactly as before. UseGET legacy-auth/logoutinstead for a legacy-bridge session — it proxies gama-api's own logout, which does hold server-side state on gama-api's side even though this backend doesn't.
Revocation — POST /api/v1/identities/tokens/revoke ([Permission(policy: null)], i.e.
requires being authenticated first) invalidates the current token
(IdentitiesController.cs:300-324).
Caveat (see ANALYZE.md B7): SetAuthenticationTokenAsync is called with the same
provider/purpose pair every time a token is generated for a user — Identity's token store keeps
one token per (user, provider, purpose) tuple. Generating a new token for a user (e.g. logging in
on a second device) overwrites the previous one; whether the old token is then rejected depends on
the underlying provider's validation semantics — treat "one active token per user" as the working
assumption until verified otherwise, and avoid depending on multiple concurrently valid tokens for
the same account.
Both the Identity cookie scheme and this token scheme are accepted together wherever
[Permission(...)] is used — PermissionAttribute sets
AuthenticationSchemes = "{IdentityConstants.ApplicationScheme},{TokenAuthenticationScheme}"
(src/Core/Common/Identity/PermissionAttribute.cs:12), so a request authenticates if either
the cookie or the bearer token validates.
A single shared secret, used to protect a small number of trusted server-to-server or "no user context" endpoints — distinct from per-user auth entirely.
- Handler:
ApiKeyAuthenticationHandler(src/Core/Common/Identity/ApiKey/ApiKeyAuthenticationHandler.cs). ExpectsAuthorization: ApiKey {key}and compares the literal key against the root-level"ApiKey"config value (configuration.GetValue<string?>("ApiKey"),ApiKeyAuthenticationHandler.cs:34). Do not treat any value currently in a trackedappsettings.jsonas a real secret you can rely on being secret — seedocs/deployment/configuration.mdfor the general secrets callout. Rotate and externalize this value before depending on the ApiKey scheme in production. - On success it issues a
ClaimsPrincipalwith a single claim(PermissionConstants.ApiKeyPolicy, key)— no user identity, no roles. - Applied via the
[ApiKey]attribute (src/Core/Common/Identity/ApiKey/ApiKeyAttribute.cs), which setsPolicy = "ApiKey"andAuthenticationSchemes = "ApiKeyAuthenticationScheme". - Current real usage:
GET /api/v1/games/easter-egg/fortune-wheel(src/Presentation/Api/Controllers/GamesController.cs:24-26) is the only controller action in the whole API gated by[ApiKey](verified by grep acrosssrc/Presentation). It is otherwise used conceptually to protect the GamaTrain Solana payment-gateway's transaction-details lookup from the provider side (PaymentGateway.GamaTrain.ApiKeyin config, a different key from the rootApiKey— don't confuse the two; the provider-side key authenticates this backend as a client of the payment gateway, while the rootApiKeyauthenticates external callers into this backend).
Two custom AuthorizeAttribute subclasses drive everything:
PermissionAttribute(src/Core/Common/Identity/PermissionAttribute.cs) — default policy name"Permission", accepts an optionalRolesarray. Used as[Permission(policy: null)](any authenticated user, no role check) or[Permission(Roles = [nameof(Role.Admin)])](must be in that role).ApiKeyAttribute— policy"ApiKey", described above.
Both policies are registered with RequireAssertion handlers in
src/Core/Common/Startup/Startup{TUser,TRole}.cs:301-344:
"Permission"policy (lines 301-327): reads the current endpoint'sPermissionAttributemetadata (LastOrDefault()— if a controller and an action both carry one, the action's wins). Passes if either (a)permission.Rolesis non-empty and the user is in any of those roles (context.User.IsInRole(t)), or (b) the user has a claim of type"Permission"whose value case-insensitively equals the endpoint'sDisplayName— i.e. fine-grained, per-endpoint permission claims can be granted to a user independently of role membership (this is what backs the AdminPUT /api/v1/admin/identities/{userId}/permissionsaction for assigning individual endpoint permissions to non-Admin users)."ApiKey"policy (lines 329-344): passes if the endpoint carries anApiKeyAttributeand the authenticated principal has an"ApiKey"-typed claim (which only theApiKeyAuthenticationHandlerissues).
Role (src/Domain/Enumeration/Role.cs) is a flags-style smart enum with five members:
Admin, Teacher, Student, Advisor, Finance.
- Public controllers (
src/Presentation/Api/Controllers/*): almost all declare class-level[Permission(policy: null)](any authenticated user by default), then use[AllowAnonymous]on individual actions to open up specific reads (e.g.SchoolsController.GetSchools,IdentitiesController.Login/Register/GenerateToken). Several controllers instead put[AllowAnonymous]at the class level (BoardsController,LocationsController,SubjectsController,TagsController,TopicsController,GradesController,VotingPowersController,LanguagesController,FilesController) — these are anonymous end to end at the HTTP-auth-attribute layer;VotingPowersController's bulk-importPOSTadditionally verifies an in-body signature as its access control instead of a standard auth attribute (seeendpoints.md).IdentitiesControllerandGamesControllerandHomeControllerandExamsControllerskip the class-level attribute entirely and annotate every action individually (seeendpoints.mdfor the per-action breakdown);HomeControllerin particular has no auth attribute anywhere in the file and no[Route]/[ApiVersion]either — it's a thin non-API MVC controller that redirects/to/swagger(src/Presentation/Api/Controllers/HomeController.cs), not a documented API surface. - Admin controllers (
src/Presentation/Api/Areas/Admin/Controllers/*): every one of the 18 files declares class-level[Permission(Roles = [nameof(Role.Admin)])]plus[Common.DataAnnotation.Area(nameof(Admin), "Admin")]and routeapi/v{version:apiVersion}/[area]/[controller](resolving toapi/v1/admin/...). No action in any Admin controller carries[AllowAnonymous]or a different role — the whole area is uniformly Admin-only. - Finance area (
src/Presentation/Api/Areas/Finance/Controllers/PaymentsController.cs): same route shape (api/v1/finance/payments), but gated by[Permission(Roles = [nameof(Role.Finance)])]— a different role from Admin. An Admin user who is not also granted theFinancerole (or an equivalent per-endpoint permission claim) cannot call it.
- To call anything under
Areas/Admin, authenticate as a user in theAdminrole (cookie or bearer token — both schemes are accepted perPermissionAttribute'sAuthenticationSchemes). - To call
Areas/Finance, the user must be in theFinancerole specifically (or hold a matching per-endpointPermissionclaim assigned via the Admin identities endpoint). - To call
[ApiKey]-gated actions, send the shared key asAuthorization: ApiKey {key}— no user session is involved or created. - For everything else, check the action's own attributes in
endpoints.md—[AllowAnonymous]always wins over a class-level[Permission], but the reverse (a class-level[AllowAnonymous]with a stricter action-level attribute) is not used anywhere in this codebase.