Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions PROJECT_SNAPSHOT.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,29 @@ be treated as "someone already fixed this."
pre-existing `tokens/old`) now **cryptographically verifies its HS256 signature** against a new
`Core:JwtSigningSecret` (real key, obtained from the gama-api team, not yet populated anywhere) —
closing a real forgeable-token gap that existed in `tokens/old` before this change and that an
earlier revision of this bridge would have inherited/widened. Trade-off: a legacy-bridge session
can't be revoked early via `tokens/revoke` (JWTs are stateless) and its lifetime is governed by
gama-api's own token expiry, not this app's configurable token lifespan. `register`/`recovery` are
pure passthroughs (gama-api never returns a token for those flows). Entirely temporary — this
whole bridge, plus the
pre-existing `tokens/old`, is meant to be deleted once the frontend fully migrates off gama-api.
earlier revision of this bridge would have inherited/widened. Trade-off: `tokens/revoke` (this
backend's own store) can't touch a legacy-bridge session, since JWTs are stateless here — use
the bridge's own `GET logout` instead (added 2026-07-13, see below) to end one early. Session
lifetime is otherwise governed by gama-api's own token expiry, not this app's configurable token
lifespan. `register`/`recovery` are pure passthroughs (gama-api never returns a token for those
flows). Entirely temporary — this whole bridge, plus the pre-existing `tokens/old`, is meant to
be deleted once the frontend fully migrates off gama-api.
- **Legacy-auth bridge logout added** (2026-07-13 — see
[`docs/api/authentication.md`](docs/api/authentication.md)'s "Legacy-auth bridge" section):
`GET legacy-auth/logout` proxies gama-api's own `GET /users/logout` (`Core:Logout` config,
bearer-auth), relaying the caller's raw legacy JWT straight from the `Authorization` header. This
is the one legacy-bridge operation that *does* end a session early, closing the gap called out in
the entry above.
- **Legacy-auth bridge logout blocklist added** (2026-07-14 — see
[`docs/api/authentication.md`](docs/api/authentication.md)): the 2026-07-13 logout endpoint above
only ended the session on gama-api's side — `ValidateLegacyJwtAsync` validates signature/issuer/
audience/expiry entirely offline, with no way to know a token was just logged out, so the same
JWT kept authenticating against *this* backend until its own `exp` naturally lapsed. Fixed by
having `IdentityService.LegacyLogoutAsync` write the token (SHA-256-hashed, not raw) to
`ICacheProvider`/Redis on a successful proxy logout, TTL'd to the token's own remaining lifetime;
`VerifyLegacyTokenAsync` (per-request auth) and `GenerateTokenByCoreTokenAsync` (`tokens/old`)
both check that blocklist right after signature validation. `SyncLegacyAuthAsync` (login/google)
intentionally doesn't check it — a fresh login token can't already be blocklisted.
- **Quota-based subscription system built** (2026-07-10, phase 1 — see
[`docs/business/subscriptions.md`](docs/business/subscriptions.md)): `SubscriptionPlan` no
longer carries a price — pricing moved to `SubscriptionPlanPrice` (regional-pricing-ready,
Expand Down
23 changes: 21 additions & 2 deletions docs/api/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ alongside `tokens/old` above — once the frontend fully migrates.
(`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 calls `login`
afterward to actually get a session, which is where sync happens.
- `GET logout` proxies gama-api's `GET /users/logout` (`ICoreProvider.LegacyLogoutAsync`,
`Core:Logout` config) — the caller's raw legacy JWT is read straight from the incoming
`Authorization` header (`TokenAuthenticationHandler.GetTokenFromHeader`) and relayed unchanged as
gama-api's own `bearerAuth`. Unlike register/recovery, this is not a pure passthrough on the
local side: on a successful proxy call, `IdentityService.BlockLegacyTokenAsync` writes the
token to a local blocklist (`ICacheProvider`/Redis, keyed by a SHA-256 hash of the token, not the
raw token) with a TTL equal to the token's own remaining `exp`. `ValidateLegacyJwtAsync` only
checks signature/issuer/audience/expiry and has no way to know gama-api ended the session
server-side, so without this the same JWT would otherwise keep authenticating against this
backend until it naturally expired even after logout. `VerifyLegacyTokenAsync` (per-request auth)
and `GenerateTokenByCoreTokenAsync` (`tokens/old`) both check the blocklist via
`IsLegacyTokenBlockedAsync` immediately after signature validation; `SyncLegacyAuthAsync`
(login/google) does not, since a fresh login always gets a brand-new token from gama-api that
can't already be in the blocklist. This is the one legacy-bridge operation that **does** end a
session early on both sides, unlike the trade-off described below for `tokens/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
Expand Down Expand Up @@ -153,8 +168,12 @@ session):
`IdentityOptions:Tokens:ApiDataProtectorTokenProviderOptions:TokenLifespan` that governs normal
opaque-token sessions.
- **`tokens/revoke` cannot end a legacy-bridge session early.** JWTs are self-contained/stateless —
there is no server-side store to invalidate. This only affects sessions started via
`legacy-auth/login`/`google`; native opaque-token sessions revoke exactly as before.
there is no server-side store *here* to invalidate, and `tokens/revoke` only ever touches this
app's own opaque-token store. This only affects sessions started via `legacy-auth/login`/`google`;
native opaque-token sessions revoke exactly as before. Use **`GET legacy-auth/logout`** instead
for a legacy-bridge session — it proxies gama-api's own logout (ending the session there) *and*
writes the token to this backend's own short-lived blocklist (see above), so it's the one
operation that ends a legacy-bridge session on both sides at once.

**Revocation** — `POST /api/v1/identities/tokens/revoke` (`[Permission(policy: null)]`, i.e.
requires being authenticated first) invalidates the current token
Expand Down
1 change: 1 addition & 0 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ string is parsed internally instead) — when `CoreId`, `id` is resolved against
| POST | `google` | Proxy gama-api googleAuth; same sync behavior as `login` | Anonymous | `LegacyGoogleAuthRequestViewModel` (body) | `LegacyAuthTokenResponseViewModel` |
| POST | `register` | Pure passthrough to gama-api register (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` |
| POST | `recovery` | Pure passthrough to gama-api recovery/reset-password (multi-step OTP); no local sync, no token | Anonymous | `LegacyOtpFlowRequestViewModel` (body) | `LegacyMessageResponseViewModel` |
| GET | `logout` | Proxies gama-api's `GET /users/logout`, relaying the caller's raw legacy JWT from the `Authorization` header; on success also records the token in a local blocklist (TTL = token's remaining `exp`) so this backend stops honoring it too | Anonymous (token supplied via header, validated by gama-api itself) | none (`Authorization: Bearer {gama-api JWT}` header) | `Void` |

### LanguagesController
`src/Presentation/Api/Controllers/LanguagesController.cs` — class-level `[Permission(policy: null)]` + `[AllowAnonymous]` (whole controller anonymous)
Expand Down
11 changes: 7 additions & 4 deletions docs/business/identity-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ standard Identity join/claim/token tables).
## Legacy-auth bridge (temporary, migration-only)

While gama-api (the old backend) is still in use, `LegacyAuthBridgeController`
(`api/v1/legacy-auth`) proxies its `login`/`register`/`recovery`/`googleAuth` flows so users who
only ever had an old-backend account can keep authenticating without a separate "migrate your
account" step. On a successful `login`/`google` call, `IdentityService.SyncLegacyAuthAsync`
(`api/v1/legacy-auth`) proxies its `login`/`register`/`recovery`/`googleAuth`/`logout` flows so
users who only ever had an old-backend account can keep authenticating without a separate "migrate
your account" step. On a successful `login`/`google` call, `IdentityService.SyncLegacyAuthAsync`
(`IdentityService.cs`) links or creates the local `ApplicationUser`:

1. Look up by `CoreId` (the existing FK linking a local user to their old-backend id).
Expand All @@ -63,7 +63,10 @@ never has to change anything and the frontend never has to know two backends are
mechanism, its required `Core:JwtSigningSecret` (real signature verification, not optional — a
forged token otherwise authenticates as any linked account), and its trade-offs (notably: a
legacy-bridge session can't be revoked early via `tokens/revoke`, since it isn't backed by any
server-side token store). This whole bridge —
server-side token store here — `GET legacy-auth/logout` covers that case instead, by proxying
gama-api's own logout *and* recording the token in a local short-lived blocklist so this backend
also stops honoring it immediately, rather than only relying on gama-api-side state). This whole
bridge —
controller, the `Legacy*` methods on `ICoreProvider`/`IIdentityService`, and
`VerifyLegacyTokenAsync` — is temporary and will be removed once the frontend fully migrates off
gama-api.
Expand Down
7 changes: 7 additions & 0 deletions src/Application/Interface/IIdentityService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ public interface IIdentityService
Task<ResultData<LegacyBridgeTokenResponseDto>> LegacyGoogleAuthAsync([NotNull] LegacyGoogleAuthRequestDto requestDto);
Task<ResultData<LegacyMessageResponseDto>> LegacyRegisterAsync([NotNull] LegacyOtpFlowRequestDto requestDto);
Task<ResultData<LegacyMessageResponseDto>> LegacyRecoveryAsync([NotNull] LegacyOtpFlowRequestDto requestDto);

/// <summary>
/// Ends a gama-api-issued session by proxying to gama-api's own GET /users/logout (bearerAuth) with the
/// caller's raw legacy JWT. Pure passthrough, same as LegacyRegisterAsync/LegacyRecoveryAsync - no local
/// state to update, since this backend never stores the legacy token in the first place.
/// </summary>
Task<ResultData<Void>> LegacyLogoutAsync([NotNull] string token);
}
}

51 changes: 49 additions & 2 deletions src/Application/Service/IdentityService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ namespace GamaEdtech.Application.Service
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
Expand All @@ -62,6 +63,7 @@ public partial class IdentityService(Lazy<IUnitOfWorkProvider> unitOfWorkProvide
: LocalizableServiceBase<IdentityService>(unitOfWorkProvider, httpContextAccessor, localizer, logger), IIdentityService, ITokenService, ISiteMapHandler
{
private const string RolesCacheKey = "Roles";
private const string LegacyLogoutBlocklistCacheKeyPrefix = "LegacyLogoutBlocklist_";

public async Task<ResultData<ListDataSource<ApplicationUserDto>>> GetUsersAsync(ListRequestDto<ApplicationUser>? requestDto = null)
{
Expand Down Expand Up @@ -615,7 +617,7 @@ public async Task<ResultData<GenerateUserTokenResponseDto>> GenerateUserTokenAsy
try
{
var validation = await ValidateLegacyJwtAsync(token);
if (!validation.IsValid)
if (!validation.IsValid || await IsLegacyTokenBlockedAsync(token))
{
return null;
}
Expand Down Expand Up @@ -691,6 +693,32 @@ private async Task<TokenValidationResult> ValidateLegacyJwtAsync(string? token)
});
}

/// <summary>
/// Blocks a legacy JWT that GET legacy-auth/logout just ended on gama-api's side. ValidateLegacyJwtAsync
/// only checks signature/issuer/audience/expiry - it has no way to know gama-api already invalidated the
/// session server-side, so without this the same token would keep authenticating here until it naturally
/// expired. Keyed by a hash of the token (not the raw token) so it isn't sitting in the cache in plaintext;
/// TTL matches the token's own remaining lifetime, since it's a no-op once the token would fail expiry
/// validation anyway.
/// </summary>
private async Task BlockLegacyTokenAsync(string token)
{
var validation = await ValidateLegacyJwtAsync(token);
if (validation.IsValid && validation.SecurityToken is JsonWebToken jwt)
{
await cacheProvider.Value.SetAsync(BuildLegacyLogoutBlocklistCacheKey(token), true, new DistributedCacheEntryOptions
{
AbsoluteExpiration = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero),
});
}
}

private async Task<bool> IsLegacyTokenBlockedAsync(string? token)
=> token is not null && await cacheProvider.Value.GetAsync<bool?>(BuildLegacyLogoutBlocklistCacheKey(token)) == true;

private static string BuildLegacyLogoutBlocklistCacheKey(string token)
=> $"{LegacyLogoutBlocklistCacheKeyPrefix}{Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)))}";

public async Task<ResultData<bool>> RemoveUserTokenAsync([NotNull] RemoveUserTokenRequestDto requestDto)
{
try
Expand Down Expand Up @@ -1358,7 +1386,7 @@ public async Task<ResultData<GenerateUserTokenResponseDto>> GenerateTokenByCoreT
try
{
var data = await ValidateLegacyJwtAsync(requestDto.Token);
if (!data.IsValid)
if (!data.IsValid || await IsLegacyTokenBlockedAsync(requestDto.Token))
{
return new(OperationResult.Failed)
{
Expand Down Expand Up @@ -1505,6 +1533,25 @@ public async Task<ResultData<LegacyMessageResponseDto>> LegacyRecoveryAsync([Not
}
}

public async Task<ResultData<Void>> LegacyLogoutAsync([NotNull] string token)
{
try
{
var result = await coreProvider.Value.LegacyLogoutAsync(new() { Token = token });
if (result.OperationResult is OperationResult.Succeeded)
{
await BlockLegacyTokenAsync(token);
}

return result;
}
catch (Exception exc)
{
Logger.Value.LogException(exc);
return new(OperationResult.Failed) { Errors = new[] { new Error { Message = exc.Message }, } };
}
}

/// <summary>
/// Shared by LegacyLoginAsync/LegacyGoogleAuthAsync (the only gama-api flows that return a token): decodes
/// the legacy JWT to get CoreId/identity (same signature-skipping validation as GenerateTokenByCoreTokenAsync),
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Data/Dto/Identity/LegacyLogoutRequestDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace GamaEdtech.Data.Dto.Identity
{
public sealed class LegacyLogoutRequestDto
{
public required string Token { get; set; }
}
}
26 changes: 26 additions & 0 deletions src/Infrastructure/Infrastructure/Provider/Core/CoreProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ namespace GamaEdtech.Infrastructure.Provider.Core

using static GamaEdtech.Common.Core.Constants;

using Void = Common.Data.Void;

public sealed class CoreProvider(Lazy<IConfiguration> configuration, Lazy<IHttpProvider> httpProvider, Lazy<IStringLocalizer<CoreProvider>> localizer
, Lazy<ILogger<CoreProvider>> logger)
: InfrastructureBase<CoreProvider>(httpProvider, localizer, logger), ICoreProvider
Expand Down Expand Up @@ -365,6 +367,30 @@ public async Task<ResultData<LegacyMessageResponseDto>> LegacyRecoveryAsync([Not
}
}

public async Task<ResultData<Void>> LegacyLogoutAsync([NotNull] LegacyLogoutRequestDto requestDto)
{
try
{
var response = await HttpProvider.Value.GetAsync<IHttpRequest, CoreResponse<object?>, IHttpRequest>(new()
{
Uri = configuration.Value.GetValue<string>("Core:Logout"),
Request = null,
HeaderParameters = [("Authorization", $"Bearer {requestDto.Token}")],
});
return response switch
{
null => new(OperationResult.Failed) { Errors = [new() { Message = Localizer.Value["GeneralError"], }] },
{ Status: 1 } => new(OperationResult.Succeeded) { Data = new() },
_ => new(OperationResult.NotValid) { Errors = [new() { Message = response.Message ?? Localizer.Value["GeneralError"], }] },
};
}
catch (Exception exc)
{
Logger.Value.LogException(exc);
return new(OperationResult.Failed) { Errors = [new() { Message = exc.Message, }] };
}
}

private async Task<LegacyAuthResponseDto> MapAuthResultAsync(CoreAuthUserInfoResponse? info, string jwtToken)
{
LegacyAuthResponseDto result = new()
Expand Down
1 change: 1 addition & 0 deletions src/Infrastructure/Interface/ICoreProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ public interface ICoreProvider
Task<ResultData<LegacyAuthResponseDto>> LegacyGoogleAuthAsync([NotNull] LegacyGoogleAuthRequestDto requestDto);
Task<ResultData<LegacyMessageResponseDto>> LegacyRegisterAsync([NotNull] LegacyOtpFlowRequestDto requestDto);
Task<ResultData<LegacyMessageResponseDto>> LegacyRecoveryAsync([NotNull] LegacyOtpFlowRequestDto requestDto);
Task<ResultData<Void>> LegacyLogoutAsync([NotNull] LegacyLogoutRequestDto requestDto);
}
}
36 changes: 36 additions & 0 deletions src/Presentation/Api/Controllers/LegacyAuthBridgeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace GamaEdtech.Presentation.Api.Controllers
using GamaEdtech.Application.Interface;
using GamaEdtech.Common.Core;
using GamaEdtech.Common.Data;
using GamaEdtech.Common.Identity;
using GamaEdtech.Presentation.ViewModel.Identity;

using Microsoft.AspNetCore.Authorization;
Expand All @@ -16,6 +17,8 @@ namespace GamaEdtech.Presentation.Api.Controllers

using static GamaEdtech.Common.Core.Constants;

using Void = Common.Data.Void;

/// <summary>
/// Temporary proxy to gama-api's login/register/recovery/googleAuth during the old-backend migration. login/google
/// additionally sync the local user and hand back gama-api's own token unchanged - TokenAuthenticationHandler
Expand Down Expand Up @@ -133,5 +136,38 @@ public async Task<IActionResult<LegacyMessageResponseViewModel>> Recovery([NotNu
return Ok<LegacyMessageResponseViewModel>(new(new Error { Message = exc.Message }));
}
}

/// <summary>
/// Ends the caller's gama-api session by proxying to gama-api's own GET /users/logout with their raw legacy
/// JWT. Pure passthrough, same as Register/Recovery - this backend never stored the token, so there's
/// nothing local to invalidate; gama-api itself validates and revokes it. Not to be confused with
/// IdentitiesController.Logout (Identity cookie) or tokens/revoke (opaque bearer token), neither of which
/// can end a legacy-bridge session - see authentication.md.
/// </summary>
[HttpGet("logout"), Produces(typeof(ApiResponse<Void>))]
public async Task<IActionResult<Void>> Logout()
{
try
{
var token = TokenAuthenticationHandler.GetTokenFromHeader(Request);
if (string.IsNullOrEmpty(token))
{
return Ok<Void>(new(new Error { Message = "Missing Authorization token" }));
}

var result = await identityService.Value.LegacyLogoutAsync(token);

return Ok<Void>(new(result.Errors)
{
Data = result.Data,
});
}
catch (Exception exc)
{
Logger.Value.LogException(exc);

return Ok<Void>(new(new Error { Message = exc.Message }));
}
}
}
}
Loading