diff --git a/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs b/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs index a52ddd9..cc8fdaa 100644 --- a/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs @@ -10,6 +10,7 @@ using Loopless.Application.Features.Users.SetUserRole; using Loopless.Application.Features.Users.UpdateCurrentUser; using MediatR; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace Loopless.Api.Endpoints; @@ -21,12 +22,14 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder var group = app.MapGroup("/api/v1").WithTags("Auth"); group.MapPost("/auth/register", async ( + HttpContext context, [FromBody] RegisterCommand command, ISender sender, CancellationToken ct) => { var result = await sender.Send(command, ct); - return Results.Ok(result); + AppendRefreshCookie(context, result.RefreshToken); + return Results.Ok(result with { RefreshToken = null }); }) .AllowAnonymous() .RequireRateLimiting("auth") @@ -36,12 +39,14 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder ; group.MapPost("/auth/login", async ( + HttpContext context, [FromBody] LoginQuery query, ISender sender, CancellationToken ct) => { var result = await sender.Send(query, ct); - return Results.Ok(result); + AppendRefreshCookie(context, result.RefreshToken); + return Results.Ok(result with { RefreshToken = null }); }) .AllowAnonymous() .RequireRateLimiting("auth") @@ -52,12 +57,14 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder ; group.MapPost("/auth/github-sso", async ( + HttpContext context, [FromBody] GitHubSsoCommand command, ISender sender, CancellationToken ct) => { var result = await sender.Send(command, ct); - return Results.Ok(result); + AppendRefreshCookie(context, result.RefreshToken); + return Results.Ok(result with { RefreshToken = null }); }) .AllowAnonymous() .RequireRateLimiting("auth") @@ -68,27 +75,34 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder ; group.MapPost("/auth/refresh", async ( - [FromBody] RefreshTokenCommand command, + HttpContext context, ISender sender, CancellationToken ct) => { - var result = await sender.Send(command, ct); - return Results.Ok(result); + // Refresh token lives in an httpOnly cookie (not the body / not JS-readable). + var refreshToken = context.Request.Cookies[RefreshCookieName]; + if (string.IsNullOrEmpty(refreshToken)) + return Results.Unauthorized(); + var result = await sender.Send(new RefreshTokenCommand(refreshToken), ct); + AppendRefreshCookie(context, result.RefreshToken); // rotate + return Results.Ok(new { accessToken = result.AccessToken, expiresIn = result.ExpiresIn }); }) .AllowAnonymous() .RequireRateLimiting("auth") .WithName("RefreshToken") - .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized) - .ProducesValidationProblem() ; group.MapPost("/auth/logout", async ( - [FromBody] LogoutCommand command, + HttpContext context, ISender sender, CancellationToken ct) => { - await sender.Send(command, ct); + var refreshToken = context.Request.Cookies[RefreshCookieName]; + if (!string.IsNullOrEmpty(refreshToken)) + await sender.Send(new LogoutCommand(refreshToken), ct); + DeleteRefreshCookie(context); return Results.NoContent(); }) .RequireAuthorization() @@ -180,4 +194,32 @@ public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder return app; } + + private const string RefreshCookieName = "loopless_rt"; + + // The refresh token lives ONLY in an httpOnly cookie — never in the response body or + // JS-readable storage. Path-scoped to /api/v1/auth so it isn't sent on every API call. + private static void AppendRefreshCookie(HttpContext context, string? refreshToken) + { + if (string.IsNullOrEmpty(refreshToken)) return; + context.Response.Cookies.Append(RefreshCookieName, refreshToken, new CookieOptions + { + HttpOnly = true, + Secure = context.Request.IsHttps, // true in prod (HTTPS via forwarded proto); false on dev http + SameSite = SameSiteMode.Lax, // web/admin and api are same-site (gjirafa.dev) + Path = "/api/v1/auth", + MaxAge = TimeSpan.FromDays(14), + IsEssential = true, + }); + } + + private static void DeleteRefreshCookie(HttpContext context) + { + context.Response.Cookies.Delete(RefreshCookieName, new CookieOptions + { + Path = "/api/v1/auth", + Secure = context.Request.IsHttps, + SameSite = SameSiteMode.Lax, + }); + } } diff --git a/devops/helm/loopless/templates/keycloak.yaml b/devops/helm/loopless/templates/keycloak.yaml index 45f0c03..5c9680b 100644 --- a/devops/helm/loopless/templates/keycloak.yaml +++ b/devops/helm/loopless/templates/keycloak.yaml @@ -76,6 +76,21 @@ spec: secretKeyRef: name: {{ .Values.existingSecret }} key: KEYCLOAK_ADMIN_PASSWORD + # GitHub SSO: the realm import substitutes ${GITHUB_CLIENT_ID/SECRET}. Without these + # the literal "placeholder" is used and SSO fails. optional:true so a missing key + # never blocks Keycloak startup (falls back to placeholder = SSO disabled). + - name: GITHUB_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: GITHUB_CLIENT_ID + optional: true + - name: GITHUB_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: GITHUB_CLIENT_SECRET + optional: true ports: - containerPort: 8080 name: http diff --git a/devops/k8s/project-01/README.md b/devops/k8s/project-01/README.md index d54b95c..b33ddda 100644 --- a/devops/k8s/project-01/README.md +++ b/devops/k8s/project-01/README.md @@ -43,7 +43,12 @@ $KCCLIENT = 'reuse-your-keycloak-client-secret' $KCADMIN = 'NEW-keycloak-admin-console-password' $MINIOUSER = 'loopless-minio' $MINIOPASS = 'NEW-strong-minio-secret' -$PAT = 'ghp_your-github-token' +$PAT_MODELS = 'github_pat_models-read-only' # AI chat + embeddings (GitHub Models) +$PAT_CONTENTS = 'github_pat_contents-read-only' # commit sync only (least privilege; separate token) +# --- GitHub OAuth app for Keycloak GitHub SSO. The app MUST have the prod callback registered: +# https://api.project-01.gjirafa.dev/auth/realms/loopless/broker/github/endpoint --- +$GHID = 'github-oauth-client-id' +$GHSECRET = 'github-oauth-client-secret' # --- Observability (DO-5/6/7/12). NEW: invent a Grafana admin password and paste the # incoming-webhook URLs the aiops triage service forwards alerts to. --- $GRAFANA = 'NEW-grafana-admin-password' @@ -66,9 +71,11 @@ kubectl create secret generic loopless-secrets -n project-01 ` --from-literal=Storage__SecretKey="$MINIOPASS" ` --from-literal=MINIO_ROOT_USER="$MINIOUSER" ` --from-literal=MINIO_ROOT_PASSWORD="$MINIOPASS" ` - --from-literal=OpenAI__ApiKey="$PAT" ` - --from-literal=OpenAI__EmbeddingsApiKey="$PAT" ` - --from-literal=GitHub__Token="$PAT" ` + --from-literal=OpenAI__ApiKey="$PAT_MODELS" ` + --from-literal=OpenAI__EmbeddingsApiKey="$PAT_MODELS" ` + --from-literal=GitHub__Token="$PAT_CONTENTS" ` + --from-literal=GITHUB_CLIENT_ID="$GHID" ` + --from-literal=GITHUB_CLIENT_SECRET="$GHSECRET" ` --from-literal=GRAFANA_ADMIN_PASSWORD="$GRAFANA" ` --from-literal=SLACK_WEBHOOK_URL="$SLACK" ` --from-literal=DISCORD_WEBHOOK_URL="$DISCORD" ` diff --git a/frontend/client/app/(protected)/onboarding/page.tsx b/frontend/client/app/(protected)/onboarding/page.tsx index c1b2972..439fed0 100644 --- a/frontend/client/app/(protected)/onboarding/page.tsx +++ b/frontend/client/app/(protected)/onboarding/page.tsx @@ -4,7 +4,8 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useOnboardingStore } from "@/stores/onboardingStore"; import { useAuthStore } from "@/stores/authStore"; -import { setUserRole, refreshToken, getMe } from "@/lib/api/auth"; +import { setUserRole, getMe } from "@/lib/api/auth"; +import { getValidAccessToken } from "@/lib/auth/token"; import { RoleSelectionStep } from "@/components/onboarding/RoleSelectionStep"; export default function OnboardingPage() { @@ -23,14 +24,8 @@ export default function OnboardingPage() { setIsPending(true); try { await setUserRole(role); - const storedRefresh = useAuthStore.getState().refreshToken; - if (storedRefresh) { - const tokens = await refreshToken(storedRefresh); - useAuthStore.getState().setToken(tokens.accessToken); - if (tokens.refreshToken) { - useAuthStore.getState().setRefreshToken(tokens.refreshToken); - } - } + // Force a token refresh (via the httpOnly cookie) so the new role lands in the JWT. + await getValidAccessToken(true); const updatedUser = await getMe(); setUser(updatedUser); } catch { diff --git a/frontend/client/app/(protected)/projects/[id]/page.tsx b/frontend/client/app/(protected)/projects/[id]/page.tsx index a483346..8f27ba9 100644 --- a/frontend/client/app/(protected)/projects/[id]/page.tsx +++ b/frontend/client/app/(protected)/projects/[id]/page.tsx @@ -3,6 +3,7 @@ import { use, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; +import { isHttpUrl, safeHttpUrl } from "@/lib/safeUrl"; import { motion } from "framer-motion"; import { ArrowLeft, @@ -175,7 +176,7 @@ export default function ProjectDashboardPage({ } async function handleAddLink() { - if (!linkForm.url || !linkForm.title) return; + if (!linkForm.url || !linkForm.title || !isHttpUrl(linkForm.url)) return; await addLinkMutation.mutateAsync(linkForm); setLinkForm({ url: "", title: "" }); setShowAddLink(false); @@ -934,7 +935,7 @@ export default function ProjectDashboardPage({