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
39 changes: 36 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FILE: deploy/helm/fuzefront/values-prod.yaml
run: |
set -euo pipefail
SHA="${{ steps.tag.outputs.sha }}"
# Read-modify-write against CURRENT master, not this job's (stale)
# checkout: the builds above take minutes, and pairing fresh blob-SHA
Expand All @@ -199,14 +200,46 @@ jobs:
RESP=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE}?ref=master")
CUR_SHA=$(jq -r .sha <<<"$RESP")
jq -r .content <<<"$RESP" | base64 -d > /tmp/vp-current.yaml
sed -E "s/^(\s*tag:).*/\1 ${SHA}/" /tmp/vp-current.yaml > /tmp/vp-new.yaml
# Bump ONLY the core-app image tags built by this workflow. A blanket
# `tag:` sed would also stomp deliberately-pinned services and the
# EXTERNAL images (authentik, unleash) whose tags are managed by hand.
# The tag: line must be the line IMMEDIATELY after the repository:
# line (true for every block in values-prod.yaml). Scoping `hot` to a
# single following line means a block that drops its tag: can never
# leak the bump onto a later, unrelated tag: (review finding).
awk -v sha="$SHA" '
hot {
hot=0
if ($0 ~ /^[[:space:]]*tag:/) {
match($0, /^[[:space:]]*/)
print substr($0, 1, RLENGTH) "tag: " sha
n++
next
}
}
/repository: ghcr\.io\/izzywdev\/fuzefront-(backend|frontend|security-service|applications-service|clock-app)$/ { hot=1 }
{ print }
END { print n+0 > "/tmp/bump-count" }
' /tmp/vp-current.yaml > /tmp/vp-new.yaml
# Guard against silent no-ops: if the file layout drifts (quoted
# values, reordered keys, renamed registry path) the awk matches
# nothing and we would otherwise "succeed" while deploying stale
# tags (review finding). Exactly 5 core-app tags must be rewritten.
COUNT=$(cat /tmp/bump-count)
if [ "$COUNT" -ne 5 ]; then
echo "::error::expected 5 core-app tag rewrites in ${FILE}, got ${COUNT} — file layout changed; update the bump step"
exit 1
fi
if cmp -s /tmp/vp-current.yaml /tmp/vp-new.yaml; then
echo "values-prod.yaml already at ${SHA} — nothing to bump"
exit 0
fi
gh api -X PUT "repos/${GITHUB_REPOSITORY}/contents/${FILE}" \
# No pipe here: a rejected PUT (e.g. ruleset denies the actor) must
# FAIL this step, not vanish into a downstream consumer's exit code.
NEW_COMMIT=$(gh api -X PUT "repos/${GITHUB_REPOSITORY}/contents/${FILE}" \
-f message="release: fuzefront images ${SHA} [skip ci]" \
-f branch=master \
-f sha="${CUR_SHA}" \
-f content="$(base64 -w0 /tmp/vp-new.yaml)" \
--jq '.commit.sha' | xargs -I{} echo "Bumped via API commit {} (server-signed)"
--jq '.commit.sha')
echo "Bumped via API commit ${NEW_COMMIT}"
4 changes: 4 additions & 0 deletions backend/security/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ dotenv.config()

const PORT = process.env.PORT || 3002
const app = createExpressApp({ serviceName: 'security-service' })
// Behind the k8s ingress every request otherwise carries the ingress IP —
// trust the first proxy hop so req.ip (rate limiting, auth logs) reflects
// the real client from X-Forwarded-For.
app.set('trust proxy', 1)
const httpServer = createServer(app)
const startTime = Date.now()

Expand Down
153 changes: 152 additions & 1 deletion backend/security/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import crypto from 'crypto'
import express from 'express'
import rateLimit from 'express-rate-limit'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/database'
import { authenticateToken } from '../middleware/auth'
import { User } from '../types/shared'
import { oidcService } from '../services/oidc'
import {
authentikPasswordLogin,
InvalidCredentialsError,
AuthentikUnavailableError,
UnsupportedFlowStageError,
} from '../services/authentikPassword'
import { runInternalProvision } from '../services/organizationProvisioning'


Expand Down Expand Up @@ -360,7 +367,18 @@
*/
router.get('/oidc/login', async (req, res) => {
const requestId = uuidv4().substring(0, 8)
console.log(`🔐 [${requestId}] OIDC login request received`)
// Structured trace: enough to diagnose a broken handoff from pod logs alone
// (misconfigured issuer/redirect/frontend-base, or an uninitialized client
// whose discovery against Authentik failed at boot).
console.log('🔐 OIDC login request received', {
requestId,
referer: req.get('Referer'),
configured: oidcService.isConfigured?.(),
initialized: oidcService.isInitialized?.(),
issuerUrl: process.env.AUTHENTIK_ISSUER_URL,
redirectUri: process.env.AUTHENTIK_REDIRECT_URI,
frontendBase: FRONTEND_BASE,
})

try {
if (!oidcService.isConfigured()) {
Expand Down Expand Up @@ -388,6 +406,139 @@
}
})


// Rate limit for the password endpoint: the flow-executor login is a
// credential-stuffing surface, so cap FAILED attempts per client before we
// ever contact Authentik (same express-rate-limit convention as
// tokenAuthRateLimiter). Successful sign-ins are never throttled.
const passwordLoginRateLimiter = rateLimit({
windowMs: 5 * 60_000,
limit: 10,
// Count ONLY rejected credentials (401) against the budget: 503s from an
// Authentik outage or an MFA-required account must not lock users out.
skipSuccessfulRequests: true,
requestWasSuccessful: (_req, res) => res.statusCode !== 401,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many sign-in attempts. Try again later.' },
})

/**
* @swagger
* /api/auth/oidc/password:
* post:
* summary: Password sign-in against Authentik (no redirect)
* description: >
* Authenticates email+password by driving Authentik's flow-executor API
* server-side, then completes the OIDC code exchange with the resulting
* Authentik session. Authentik remains the sole identity authority; the
* response shape matches /api/auth/login so the frontend treats both
* identically.
* tags: [Authentication]
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email, password]
* properties:
* email: { type: string }
* password: { type: string }
* responses:
* 200:
* description: Authenticated — platform JWT + user
* 400:
* description: Missing email or password
* 401:
* description: Invalid credentials
* 503:
* description: OIDC not configured, Authentik unreachable, or the
* account requires a browser flow (MFA/consent)
*/
router.post('/oidc/password', passwordLoginRateLimiter, async (req, res) => {
const requestId = uuidv4().substring(0, 8)
const { email, password } = req.body || {}

console.log('🔐 Authentik password login request', {
requestId,
hasEmail: !!email,
configured: oidcService.isConfigured?.(),
initialized: oidcService.isInitialized?.(),
})

if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' })
}
if (!oidcService.isConfigured()) {
return res.status(503).json({
error:
'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.',
})
}

try {
// Lazy re-init mirrors the monolith and /oidc/login: Authentik may not
// have been ready when this replica booted — self-heal here instead of
// 503ing until an SSO request happens to re-initialize the client.
if (!oidcService.isInitialized()) {
try {
await oidcService.initialize()
} catch (initErr) {
console.error('❌ OIDC lazy init failed', JSON.stringify({ requestId, message: (initErr as Error).message?.replace(/[\r\n]+/g, ' ') }))
return res
.status(503)
.json({ error: 'Authentication service unavailable. Try again shortly.' })
}
}

const user = await authentikPasswordLogin(email, password)

// Session + JWT minting — identical to the local login / OIDC callback.
const sessionId = uuidv4()
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
// This IS FuzeFront's identity service — the issuer of platform tokens
// (same mint as /login and the OIDC callback), not a product self-minting.
// nosemgrep: fuze-auth-self-minted-user-token, semgrep.fuze-auth-self-minted-user-token
const token = jwt.sign(
{ userId: user.id, sessionId },
process.env.JWT_SECRET!,
{ expiresIn: '24h' }
)
Comment thread
github-actions[bot] marked this conversation as resolved.
Dismissed
await db('sessions').insert({
id: sessionId,
user_id: user.id,
expires_at: expiresAt,
})

selfHealProvisioningOnLogin(user.id)

console.log('🎉 Authentik password login successful', { requestId, userId: user.id })
return res.json({ token, user, sessionId })
} catch (error) {
if (error instanceof InvalidCredentialsError) {
console.log('❌ Authentik rejected credentials', { requestId })
return res.status(401).json({ error: 'Invalid credentials' })
}
if (error instanceof UnsupportedFlowStageError) {
console.warn('⚠️ Unsupported Authentik flow stage', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') }))
return res.status(503).json({
error:
'This account requires a browser sign-in flow (e.g. MFA). Use the SSO button instead.',
})
}
if (error instanceof AuthentikUnavailableError) {
console.error('❌ Authentik unavailable', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') }))
return res
.status(503)
.json({ error: 'Authentication service unavailable. Try again shortly.' })
}
console.error('❌ Authentik password login error', { requestId }, error)
return res.status(500).json({ error: 'Authentication failed' })
}
})

/**
* @swagger
* /api/auth/oidc/callback:
Expand Down
Loading
Loading