Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
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
62 changes: 52 additions & 10 deletions backend/src/Loopless.Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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<RefreshTokenResponse>(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()
Expand Down Expand Up @@ -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,
});
}
}
15 changes: 15 additions & 0 deletions devops/helm/loopless/templates/keycloak.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions devops/k8s/project-01/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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" `
Expand Down
13 changes: 4 additions & 9 deletions frontend/client/app/(protected)/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions frontend/client/app/(protected)/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -934,7 +935,7 @@ export default function ProjectDashboardPage({
<button
type="button"
onClick={handleAddLink}
disabled={addLinkMutation.isPending || !linkForm.url || !linkForm.title}
disabled={addLinkMutation.isPending || !linkForm.url || !linkForm.title || !isHttpUrl(linkForm.url)}
className="rounded-full bg-[var(--indigo)] px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-cta)] transition-[background,transform] duration-[220ms] hover:-translate-y-px hover:bg-[var(--indigo-deep)] disabled:opacity-50"
>
{addLinkMutation.isPending ? "Adding…" : "Add Link"}
Expand All @@ -959,7 +960,7 @@ export default function ProjectDashboardPage({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<a
href={link.url}
href={safeHttpUrl(link.url)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium text-[var(--indigo)] hover:underline"
Expand Down
5 changes: 2 additions & 3 deletions frontend/client/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { AuthResponse } from "@/types/auth";
function CallbackHandler() {
const router = useRouter();
const searchParams = useSearchParams();
const { setToken, setRefreshToken, setUser } = useAuthStore();
const { setToken, setUser } = useAuthStore();
const didRun = useRef(false);

useEffect(() => {
Expand All @@ -32,7 +32,6 @@ function CallbackHandler() {
})
.then(({ data }) => {
setToken(data.accessToken);
if (data.refreshToken) setRefreshToken(data.refreshToken);
setUser(data.user);
trackEvent("user_signed_in", { method: "github_sso", role: data.user?.role });
// Admin subdomain always lands on the admin dashboard (no onboarding/discover).
Expand All @@ -43,7 +42,7 @@ function CallbackHandler() {
.catch(() => {
router.replace("/login?error=sso_failed");
});
}, [searchParams, router, setToken, setRefreshToken, setUser]);
}, [searchParams, router, setToken, setUser]);

return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
Expand Down
31 changes: 22 additions & 9 deletions frontend/client/components/auth/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,45 @@
"use client";

import { useEffect, useRef, useSyncExternalStore } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { getValidAccessToken } from "@/lib/auth/token";

type Props = {
children: React.ReactNode;
};

function subscribe() {
return () => {};
}

export function AuthGuard({ children }: Props) {
const router = useRouter();
const token = useAuthStore((s) => s.token);
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
const [checking, setChecking] = useState(true);
const didRedirect = useRef(false);

// On mount, if there's no in-memory access token (e.g. after a page reload, since tokens
// are no longer persisted), attempt a cookie-based silent refresh to restore the session
// before deciding whether to redirect to /login.
useEffect(() => {
let cancelled = false;
(async () => {
if (!useAuthStore.getState().token) {
await getValidAccessToken();
}
if (!cancelled) setChecking(false);
})();
return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (!hydrated) return;
if (checking) return;
if (!token && !didRedirect.current) {
didRedirect.current = true;
router.replace("/login");
}
}, [hydrated, token, router]);
}, [checking, token, router]);

if (!hydrated || !token) {
if (checking || !token) {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--bg)]">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--indigo)] border-t-transparent" />
Expand Down
7 changes: 4 additions & 3 deletions frontend/client/components/profile/EnterpriseProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { safeHttpUrl } from "@/lib/safeUrl";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Expand Down Expand Up @@ -338,14 +339,14 @@ export function EnterpriseProfileCard({ profile, isSelf }: Props) {
placeholder="https://yourcompany.com"
className="w-64 rounded-[10px] border border-[var(--line)] bg-[var(--bg)] px-2.5 py-1 text-sm text-[var(--ink)] outline-none focus:border-[var(--indigo)] focus:ring-[3px] focus:ring-[var(--indigo-soft)]"
/>
) : profile.website ? (
) : safeHttpUrl(profile.website) ? (
<a
href={profile.website}
href={safeHttpUrl(profile.website)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-[var(--indigo)] hover:underline"
>
{profile.website.replace(/^https?:\/\//, "").replace(/\/$/, "")}
{(profile.website ?? "").replace(/^https?:\/\//, "").replace(/\/$/, "")}
<ExternalLink className="h-2.5 w-2.5 text-[var(--mute)]/60" aria-hidden="true" />
</a>
) : (
Expand Down
21 changes: 14 additions & 7 deletions frontend/client/components/profile/FreelancerProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { safeHttpUrl } from "@/lib/safeUrl";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Briefcase,
Expand Down Expand Up @@ -677,13 +678,11 @@ function ProfileActions({
}

function LinkRow({ icon, label, href }: { icon: ReactNode; label: string; href: string }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[13px] font-medium text-[var(--ink)]/80 transition-colors duration-[220ms] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]"
>
const safeHref = safeHttpUrl(href);
const className =
"group flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[13px] font-medium text-[var(--ink)]/80 transition-colors duration-[220ms] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]";
const inner = (
<>
<span className="text-[var(--mute)]">{icon}</span>
<span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{label}
Expand All @@ -692,7 +691,15 @@ function LinkRow({ icon, label, href }: { icon: ReactNode; label: string; href:
className="h-2.5 w-2.5 text-[var(--mute)]/60 opacity-0 transition-opacity duration-[220ms] group-hover:opacity-100"
aria-hidden="true"
/>
</>
);
// A non-http(s) URL (e.g. javascript:) renders as plain, non-clickable text — never an href.
return safeHref ? (
<a href={safeHref} target="_blank" rel="noopener noreferrer" className={className}>
{inner}
</a>
) : (
<span className={className}>{inner}</span>
);
}

Expand Down
Loading
Loading