Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Trax.Api.Auth;
using Trax.Api.Auth.Jwt;
using Trax.Samples.GameServer.Auth;

namespace Trax.Samples.GameServer.Api;

/// <summary>
/// Maps a Google-issued id-token onto a Trax principal. The JWT bearer
/// handler has already verified signature, issuer, audience, and lifetime
/// against Google's JWKS before this runs.
/// </summary>
/// <remarks>
/// Demo only: every authenticated Google user is granted the Player role so
/// sample trains work out of the box. A real deployment would look up the
/// principal in the game's user table and assign roles based on account
/// state, revoke unknown subjects, etc.
/// </remarks>
internal sealed class GoogleJwtResolver : ITraxPrincipalResolver<JwtTokenInput>
{
public ValueTask<TraxPrincipal?> ResolveAsync(JwtTokenInput input, CancellationToken ct)
{
var sub = input.Principal.FindFirst("sub")?.Value;
if (string.IsNullOrWhiteSpace(sub))
return new ValueTask<TraxPrincipal?>((TraxPrincipal?)null);

var name =
input.Principal.FindFirst("name")?.Value
?? input.Principal.FindFirst("email")?.Value
?? sub;

return new ValueTask<TraxPrincipal?>(
new TraxPrincipal(
Id: sub,
DisplayName: name,
Roles: [nameof(GameRole.Player)],
PrincipalType: JwtDefaults.PrincipalType
)
);
}
}
50 changes: 42 additions & 8 deletions samples/LocalWorkers/Trax.Samples.GameServer.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,21 @@
// dispatch { trainName(mode: QUEUE) } mutations. This process does NOT run a scheduler — start the
// Scheduler project alongside this one.
//
// Authentication: fake API key via X-Api-Key header (for demonstration only)
// Admin key: admin-key-do-not-use-in-production (roles: Admin, Player)
// Player key: player-key-do-not-use-in-production (role: Player)
// Authentication (two schemes coexist, pick either per request):
//
// 1. API key via X-Api-Key header — service-to-service / scripting
// Admin key: admin-key-do-not-use-in-production (roles: Admin, Player)
// Player key: player-key-do-not-use-in-production (role: Player)
//
// 2. JWT bearer via Authorization: Bearer <google-id-token> — the Next.js
// companion app (samples/LocalWorkers/trax-samples-gameserver-web)
// signs users in with Google via NextAuth, then forwards the id-token
// to this API. Trax validates against Google's JWKS. Enable by setting
// Google:ClientId in appsettings.json to your OAuth 2.0 client id from
// Google Cloud Console.
//
// Both schemes feed the same TraxPrincipal, so [TraxAuthorize] works against
// either credential type.
//
// Prerequisites:
// 1. Start Postgres: cd Trax.Samples && docker compose up -d
Expand All @@ -25,25 +37,25 @@
// curl -H "X-Api-Key: player-key-do-not-use-in-production" \
// -X POST http://localhost:5200/trax/graphql \
// -H "Content-Type: application/json" \
// -d '{"query":"{ trains { serviceTypeName inputTypeName requiredPolicies requiredRoles inputSchema { name typeName } } }"}'
// -d '{"query":"{ operations { trains { serviceTypeName inputTypeName requiredPolicies requiredRoles inputSchema { name typeName } } } }"}'
//
// # Query a train directly (typed query from [TraxQuery])
// curl -H "X-Api-Key: player-key-do-not-use-in-production" \
// -X POST http://localhost:5200/trax/graphql \
// -H "Content-Type: application/json" \
// -d '{"query":"{ discover { lookupPlayer(input: {playerId: \"player-42\"}) { playerId rank wins losses rating } } }"}'
// -d '{"query":"{ discover { players { lookupPlayer(input: {playerId: \"player-42\"}) { playerId rank wins losses rating } } } }"}'
//
// # Query model data directly with filtering and pagination ([TraxQueryModel])
// curl -H "X-Api-Key: player-key-do-not-use-in-production" \
// -X POST http://localhost:5200/trax/graphql \
// -H "Content-Type: application/json" \
// -d '{"query":"{ discover { playerRecords(first: 10, where: { rating: { gte: 1500 } }) { nodes { playerId displayName rating } pageInfo { hasNextPage endCursor } } } }"}'
// -d '{"query":"{ discover { players { playerRecords(first: 10, where: { rating: { gte: 1500 } }) { nodes { playerId displayName rating } pageInfo { hasNextPage endCursor } } } } }"}'
//
// # Queue a heavy train for the scheduler (typed mutation from [TraxMutation])
// curl -H "X-Api-Key: player-key-do-not-use-in-production" \
// -X POST http://localhost:5200/trax/graphql \
// -H "Content-Type: application/json" \
// -d '{"query":"mutation { dispatch { processMatchResult(input: {region: \"na\", matchId: \"match-999\", winnerId: \"player-1\", loserId: \"player-2\", winnerScore: 100, loserScore: 30}, mode: QUEUE, priority: 10) { externalId workQueueId } } }"}'
// -d '{"query":"mutation { dispatch { matches { processMatchResult(input: {region: \"na\", matchId: \"match-999\", winnerId: \"player-1\", loserId: \"player-2\", winnerScore: 100, loserScore: 30}, mode: QUEUE, priority: 10) { externalId workQueueId } } } }"}'
//
// # Subscribe to real-time train lifecycle events (use Banana Cake Pop IDE):
// # subscription { onTrainStarted { metadataId trainName trainState timestamp } }
Expand All @@ -52,10 +64,14 @@
//
// # Health check (no auth required)
// curl http://localhost:5200/trax/health
//
// # Google JWT path: see ../trax-samples-gameserver-web for a Next.js app
// # that signs in with Google via NextAuth and forwards the id-token here.
// ─────────────────────────────────────────────────────────────────────────────

using Microsoft.EntityFrameworkCore;
using Trax.Api.Auth.ApiKey;
using Trax.Api.Auth.Jwt;
using Trax.Api.Extensions;
using Trax.Api.GraphQL.Extensions;
using Trax.Effect.Data.Extensions;
Expand All @@ -65,6 +81,7 @@
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
using Trax.Samples.GameServer;
using Trax.Samples.GameServer.Api;
using Trax.Samples.GameServer.Auth;
using Trax.Samples.GameServer.Data;
using Trax.Samples.GameServer.Data.Models;
Expand Down Expand Up @@ -94,12 +111,29 @@
);
});

// ── Authentication, fake API key for demonstration (NO WARRANTY, see SECURITY-DISCLAIMER.md) ──
// ── Authentication (NO WARRANTY, see SECURITY-DISCLAIMER.md) ──────────
// Two schemes coexist. Both contribute to the combined TraxAuthPolicy, so a
// route gated by that policy accepts either credential type.

// 1. Fake API keys for scripting / service-to-service.
builder.Services.AddTraxApiKeyAuth(keys =>
keys.Add(SampleKeys.AdminKey, id: "admin", nameof(GameRole.Admin), nameof(GameRole.Player))
.Add(SampleKeys.PlayerKey, id: "player", nameof(GameRole.Player))
);

// 2. JWT bearer: accept Google-issued id-tokens. The Next.js frontend obtains
// them via NextAuth and sends them as Authorization: Bearer <id-token>.
// Signature validation happens against Google's published JWKS; only tokens
// minted for our specific OAuth client id are accepted (aud claim check).
var googleClientId = builder.Configuration["Google:ClientId"];
if (!string.IsNullOrWhiteSpace(googleClientId))
{
builder.Services.AddTraxJwtAuth<GoogleJwtResolver>(
"https://accounts.google.com",
googleClientId
);
}

// ── Authorization policies ──────────────────────────────────────────────
builder.Services.AddAuthorization(options =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<UserSecretsId>a4458be6-4c07-4927-ad99-1a366d87cb3b</UserSecretsId>
</PropertyGroup>

<ItemGroup>
Expand All @@ -16,6 +17,7 @@
<PackageReference Include="Trax.Effect.Provider.Parameter" Version="1.*" />
<PackageReference Include="Trax.Api" Version="1.*" />
<PackageReference Include="Trax.Api.Auth.ApiKey" Version="1.*" />
<PackageReference Include="Trax.Api.Auth.Jwt" Version="1.*" />
<PackageReference Include="Trax.Api.GraphQL" Version="1.*" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Google": {
"_comment": "To accept Google-issued JWTs from the Next.js frontend, set ClientId to the OAuth 2.0 client id from Google Cloud Console (the same one NextAuth uses). Leave blank to disable the JWT scheme; the API will still accept API keys for scripting.",
"ClientId": ""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy to .env.local and fill in real values.

# NextAuth secret used to encrypt the session JWT. Generate with:
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
AUTH_SECRET=

# OAuth 2.0 client credentials from Google Cloud Console.
# Authorized redirect URI on the client must include:
# http://localhost:3000/api/auth/callback/google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=

# Where the Trax GameServer API is listening.
NEXT_PUBLIC_TRAX_API=http://localhost:5200
8 changes: 8 additions & 0 deletions samples/LocalWorkers/trax-samples-gameserver-web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
.next
out
.env.local
.env.*.local
*.log
.DS_Store
next-env.d.ts
94 changes: 94 additions & 0 deletions samples/LocalWorkers/trax-samples-gameserver-web/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Trax GameServer · Web

A minimal Next.js companion for `Trax.Samples.GameServer.Api`. Signs users in
with Google via NextAuth, then forwards the Google-issued id-token to the
Trax GraphQL API as an `Authorization: Bearer` credential. The API validates
the token against Google's JWKS via `AddTraxJwtAuth("https://accounts.google.com", googleClientId)`.

> **Demo only.** The resolver in `GoogleJwtResolver.cs` grants the `Player`
> role to every authenticated Google user so the sample's trains work out of
> the box. Do not copy that pattern into anything real.

## Prerequisites

- Node.js 20 or newer, npm 10 or newer
- A running `Trax.Samples.GameServer.Api` on `http://localhost:5200`
- A Google Cloud project with an OAuth 2.0 client

## 1. Create a Google OAuth client

1. Open <https://console.cloud.google.com/apis/credentials>.
2. Create a new OAuth 2.0 Client ID of type **Web application**.
3. Add authorized redirect URI:
```
http://localhost:3000/api/auth/callback/google
```
4. Save the client id and client secret.

## 2. Configure the web app

```bash
cp .env.local.example .env.local
```

Fill in:

- `AUTH_SECRET` — generate with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`
- `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` — from step 1
- `NEXT_PUBLIC_TRAX_API` — leave as `http://localhost:5200` unless the API runs elsewhere

## 3. Configure the Trax API

Open `../Trax.Samples.GameServer.Api/appsettings.json` and set:

```json
"Google": {
"ClientId": "<the same AUTH_GOOGLE_ID from step 1>"
}
```

The API uses this value as the expected `aud` claim when validating tokens.
If it is blank, the API silently skips JWT registration and only accepts
API-key credentials.

## 4. Run

```bash
# Terminal 1: API (from the repo root)
dotnet run --project Trax.Samples/samples/LocalWorkers/Trax.Samples.GameServer.Scheduler
dotnet run --project Trax.Samples/samples/LocalWorkers/Trax.Samples.GameServer.Api

# Terminal 2: web
cd Trax.Samples/samples/LocalWorkers/trax-samples-gameserver-web
npm install
npm run dev
```

Open <http://localhost:3000>, sign in, click **Discover trains**.

## How the pieces fit

```
┌────────────┐ 1. sign in ┌──────────┐
│ Browser │ ────────────────────>│ Google │
│ (Next.js) │ <── id-token ────────│ OIDC │
└────────────┘ └──────────┘
│ 2. fetch /trax/graphql
│ Authorization: Bearer <id-token>
┌────────────────────────┐
│ Trax GameServer · API │
│ │
│ AddTraxJwtAuth( │
│ "accounts.google.com"│──── 3. fetch JWKS ────> Google
│ clientId │ (cached)
│ ) │
│ │
│ GoogleJwtResolver maps │
│ sub → TraxPrincipal.Id │
└────────────────────────┘
```

NextAuth owns the interactive browser flow. The Trax API is pure JWT
validation: no cookies, no redirects, no session state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
};
Loading
Loading