From 6f870887d59d43fe254ee73b6dfb8057127ed0a1 Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Wed, 10 Dec 2025 04:46:21 +0100 Subject: [PATCH] Update API Docs --- PurrLobby/Program.cs | 134 +++++++++++++++++++++++++++++++++++++------ compose.yaml | 10 +++- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/PurrLobby/Program.cs b/PurrLobby/Program.cs index 124fac6..0772919 100644 --- a/PurrLobby/Program.cs +++ b/PurrLobby/Program.cs @@ -53,7 +53,7 @@ builder.Services.AddSwaggerGen(o => { Title = "PurrLobby API", Version = "v1", - Description = "PurrLobby is a lightweight lobby service. Many endpoints require a 'gameId' cookie to scope requests to your game. Obtain it by calling POST /session/game.", + Description = "PurrLobby is a secure lightweight lobby service with token-based authentication. All lobby endpoints require a valid session token and gameId cookie.", Contact = new OpenApiContact { Name = "PurrLobby", Url = new Uri("https://purrlobby.exil.dev") } }); @@ -65,12 +65,12 @@ builder.Services.AddSwaggerGen(o => Description = "Game scope cookie set by POST /session/game." }); - o.AddSecurityDefinition("userIdCookie", new OpenApiSecurityScheme + o.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme { - Type = SecuritySchemeType.ApiKey, - In = ParameterLocation.Cookie, - Name = "userId", - Description = "Player identity cookie generated by the server (POST /session/user)." + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Secure session token obtained from POST /auth/create. Include in Authorization header as 'Bearer ' or as 'token' query parameter." }); o.AddSecurityRequirement(new OpenApiSecurityRequirement @@ -81,6 +81,13 @@ builder.Services.AddSwaggerGen(o => Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "gameIdCookie" } }, Array.Empty() + }, + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() } }); }); @@ -109,6 +116,96 @@ app.UseStaticFiles(); app.UseSwagger(); app.UseSwaggerUI(); app.MapRazorPages(); // Enable Razor Pages + +// Authentication endpoints +app.MapPost("/auth/create", async (IAuthenticationService authService, CreateSessionRequest req, CancellationToken ct) => +{ + try + { + if (string.IsNullOrWhiteSpace(req.UserId) || string.IsNullOrWhiteSpace(req.DisplayName)) + return Results.BadRequest(new { error = "UserId and DisplayName are required" }); + + var session = await authService.CreateSessionAsync(req.UserId, req.DisplayName, ct); + return Results.Ok(session); + } + catch (ArgumentException ax) + { + return Results.BadRequest(new { error = ax.Message }); + } + catch + { + return Results.Problem("Internal server error", statusCode: 500); + } +}) +.WithTags("Authentication") +.WithOpenApi(op => +{ + op.Summary = "Create authentication session"; + op.Description = "Creates a secure session token for a user. Returns a session token that must be used for all subsequent API calls."; + op.Security.Clear(); + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status500InternalServerError); + +app.MapPost("/auth/validate", async (IAuthenticationService authService, ValidateTokenRequest req, CancellationToken ct) => +{ + try + { + if (string.IsNullOrWhiteSpace(req.Token)) + return Results.BadRequest(new { error = "Token is required" }); + + var result = await authService.ValidateTokenAsync(req.Token, ct); + return Results.Ok(result); + } + catch + { + return Results.Problem("Internal server error", statusCode: 500); + } +}) +.WithTags("Authentication") +.WithOpenApi(op => +{ + op.Summary = "Validate session token"; + op.Description = "Validates a session token and returns user information if valid."; + op.Security.Clear(); + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status500InternalServerError); + +app.MapPost("/auth/revoke", async (IAuthenticationService authService, RevokeTokenRequest req, CancellationToken ct) => +{ + try + { + if (string.IsNullOrWhiteSpace(req.Token)) + return Results.BadRequest(new { error = "Token is required" }); + + var result = await authService.RevokeTokenAsync(req.Token, ct); + return Results.Ok(result); + } + catch + { + return Results.Problem("Internal server error", statusCode: 500); + } +}) +.WithTags("Authentication") +.WithOpenApi(op => +{ + op.Summary = "Revoke session token"; + op.Description = "Revokes a session token, making it invalid for future use."; + op.Security.Clear(); + return op; +}) +.Accepts("application/json") +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status400BadRequest) +.Produces(StatusCodes.Status500InternalServerError); + static CookieOptions BuildStdCookieOptions() => new() { HttpOnly = true, @@ -200,7 +297,7 @@ app.MapPost("/lobbies", async (HttpContext http, ILobbyService service, CreateLo .WithOpenApi(op => { op.Summary = "Create a lobby"; - op.Description = "Creates a new lobby for the current game. Owner user id is server generated (cookie 'userId')."; + op.Description = "Creates a new lobby for the current game. Requires valid session token for authentication."; return op; }) .Accepts("application/json") @@ -224,7 +321,7 @@ app.MapPost("/lobbies/{lobbyId}/join", async (HttpContext http, ILobbyService se .WithOpenApi(op => { op.Summary = "Join a lobby"; - op.Description = "Adds the current cookie user (server generated userId) to the specified lobby."; + op.Description = "Adds the authenticated user to the specified lobby. Session token is automatically extracted from request."; return op; }) @@ -249,7 +346,7 @@ app.MapPost("/lobbies/{lobbyId}/leave", async (HttpContext http, ILobbyService s .WithOpenApi(op => { op.Summary = "Leave a lobby"; - op.Description = "Removes the current cookie user from the lobby."; + op.Description = "Removes the authenticated user from the lobby."; return op; }) .Produces(StatusCodes.Status200OK) @@ -271,7 +368,7 @@ app.MapGet("/lobbies/search", async (HttpContext http, ILobbyService service, in .WithOpenApi(op => { op.Summary = "Search available lobbies"; - op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies."; + op.Description = "Finds joinable lobbies for the current game. Excludes started or full lobbies. Does not require authentication."; op.Parameters.Add(new OpenApiParameter { Name = "maxRoomsToFind", @@ -303,7 +400,7 @@ app.MapGet("/lobbies/{lobbyId}", async (HttpContext http, ILobbyService service, .WithOpenApi(op => { op.Summary = "Get lobby details"; - op.Description = "Returns the lobby object including ownership flag relative to current cookie user."; + op.Description = "Returns lobby object including ownership flag relative to the authenticated user."; return op; }) .Produces(StatusCodes.Status200OK) @@ -322,7 +419,7 @@ app.MapGet("/lobbies/{lobbyId}/members", async (HttpContext http, ILobbyService .WithOpenApi(op => { op.Summary = "Get members of a lobby"; - op.Description = "Returns current members of the lobby in the current game, including readiness state."; + op.Description = "Returns current members of lobby in the current game, including readiness state. Requires authentication and membership in the lobby."; return op; }) .Produces>(StatusCodes.Status200OK) @@ -368,7 +465,7 @@ app.MapPost("/lobbies/{lobbyId}/data", async (HttpContext http, ILobbyService se .WithOpenApi(op => { op.Summary = "Set a lobby data value"; - op.Description = "Sets or updates a single property on the lobby within the current game. Broadcasts a lobby_data event to subscribers."; + op.Description = "Sets or updates a single property on lobby within the current game. Only lobby owners can set data. Broadcasts a lobby_data event to subscribers."; return op; }) .Accepts("application/json") @@ -394,7 +491,7 @@ app.MapPost("/lobbies/{lobbyId}/ready", async (HttpContext http, ILobbyService s .WithOpenApi(op => { op.Summary = "Set member ready state"; - op.Description = "Sets readiness for the specified user in the lobby. Request body must include userId and isReady. Broadcasts a member_ready event."; + op.Description = "Sets readiness for the authenticated user in the lobby. Request body must include isReady. Broadcasts a member_ready event."; return op; }) .Accepts("application/json") @@ -420,7 +517,7 @@ app.MapPost("/lobbies/{lobbyId}/start", async (HttpContext http, ILobbyService s .WithOpenApi(op => { op.Summary = "Start lobby"; - op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started."; + op.Description = "Owner only. Marks lobby as started and broadcasts lobby_started. Only lobby owners can start lobbies."; return op; }) .Produces(StatusCodes.Status200OK) @@ -488,4 +585,9 @@ app.Run(); public record SetGameRequest(Guid GameId); public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictionary? Properties); public record ReadyRequest(bool IsReady); -public record LobbyDataRequest(string Key, string Value); \ No newline at end of file +public record LobbyDataRequest(string Key, string Value); + +// Authentication DTOs +public record CreateSessionRequest(string UserId, string DisplayName); +public record ValidateTokenRequest(string Token); +public record RevokeTokenRequest(string Token); \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 6ad803b..2639388 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,9 +12,13 @@ services: build: context: . target: final - ports: - - 443:443 - - 8080:8080 + labels: + - "traefik.enable=true" + - "traefik.http.routers.purrlobby.rule=Host(`purrlobby.exil.dev`)" + - "traefik.http.routers.purrlobby.entrypoints=websecure" + - "traefik.http.routers.purrlobby.tls.certresolver=letsencrypt" + - "traefik.http.services.purrlobby.loadbalancer.server.port=8080" # internal port your app listens on + # The commented out section below is an example of how to define a PostgreSQL # database that your application can use. `depends_on` tells Docker Compose to