4 Commits
Author SHA1 Message Date
github-actions[bot] f6b0831121 docs(javadoc): publish 2.0.0 2026-05-12 14:36:56 +00:00
Relism 26d6386427 Create CNAME 2026-05-12 16:18:12 +02:00
github-actions[bot] 515dceed7e docs(javadoc): release 2.0.0 2026-05-12 13:57:39 +00:00
Relism e7fa1d8639 chore: initialize gh-pages 2026-05-11 14:32:16 +02:00
1706 changed files with 159779 additions and 64945 deletions
View File
-24
View File
@@ -1,24 +0,0 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<!--
Used only by .gitea/workflows/publish-maven.yml (mvn -s .gitea/maven-settings.xml deploy).
Not used for local builds. PACKAGES_TOKEN is a real personal access token (write:package
scope) on the Relism account, read from the env var the workflow exports — never written
to disk. Deliberately not Gitea's own per-job GITEA_TOKEN: that token can't publish to
any package registry at all, a known unimplemented limitation
(https://github.com/go-gitea/gitea/issues/23642) — confirmed here by testing: it
authenticated fine against the plain API but still got 401 from this endpoint.
Basic auth (username/password): the <httpHeaders> form Gitea's own docs show for this is
honored by Maven's resolver (used for reading <repositories>) but not reliably by the
wagon-http provider maven-deploy-plugin actually uploads through.
-->
<servers>
<server>
<id>gitea</id>
<username>Relism</username>
<password>${env.PACKAGES_TOKEN}</password>
</server>
</servers>
</settings>
-49
View File
@@ -1,49 +0,0 @@
name: Publish Maven packages
# Flash's own POM keeps `2.1.0-SNAPSHOT` as its committed version — that's what local
# `mvn install` (Pathway's normal dev loop, see its pom.xml `flash.version` comment) always
# produces, and changing it here would break that. Gitea's Maven registry, unlike a real
# snapshot repository, refuses to re-publish an existing name+version (must delete first —
# see https://docs.gitea.com/usage/packages/maven#publish-a-package), so every push instead
# publishes under a throwaway version stamped with the commit it built from
# (`2.1.0-<short-sha>`), via `versions:set` on a checkout copy — never touching the committed
# POMs. Consumers (Pathway's `docker` Maven profile) pin `flash.version` to one specific
# published build and bump it by hand to pick up newer Flash changes; see
# pathway/pom.xml's `docker` profile for the other half of this.
on:
push:
branches: [master]
jobs:
publish:
runs-on: ubuntu-latest
# No actions/checkout here on purpose: it's a Node-based action, and this container
# (chosen for its preinstalled mvn/JDK 21) has no Node — checkout would fail with
# "node: executable file not found". A plain git clone needs neither.
container:
image: maven:3.9-eclipse-temurin-21
steps:
- name: Checkout
run: |
apt-get update && apt-get install -y --no-install-recommends git
git clone https://git.pixel-services.com/Relism/Flash5.git .
git checkout ${{ gitea.sha }}
- name: Stamp every module with a commit-scoped version
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
mvn -B versions:set -DnewVersion="2.1.0-${SHORT_SHA}" -DprocessAllModules=true -DgenerateBackupPoms=false
echo "Publishing as 2.1.0-${SHORT_SHA}"
- name: Deploy to the Gitea Maven registry
env:
# Not GITEA_TOKEN: Gitea's own job token can't publish to package registries at
# all (a known, still-unimplemented limitation — see
# https://github.com/go-gitea/gitea/issues/23642). Confirmed by testing: GITEA_TOKEN
# authenticated fine against the plain API but still got 401 from this endpoint no
# matter the auth style. PACKAGES_TOKEN is a real PAT with write:package scope.
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
run: |
mvn -B -s .gitea/maven-settings.xml -DskipTests deploy \
-DaltReleaseDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven \
-DaltSnapshotDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven
-17
View File
@@ -1,17 +0,0 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>Personal</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
<server>
<id>Personal-snapshots</id>
<username>${env.MAVEN_USERNAME}</username>
<password>${env.MAVEN_PASSWORD}</password>
</server>
</servers>
</settings>
-79
View File
@@ -1,79 +0,0 @@
name: CI
on:
push:
branches:
- master
- 'feature/**'
- 'fix/**'
- 'hotfix/**'
tags-ignore:
- 'v*'
pull_request:
branches:
- master
jobs:
build:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
server-id: Personal
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Install h2spec 2.6.0
run: |
curl --fail --location --silent --show-error \
--output /tmp/h2spec.tar.gz \
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
| sha256sum --check
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
- name: Install nghttp client
run: |
sudo apt-get update
sudo apt-get install --yes nghttp2-client
- name: Install grpcurl 1.9.3
run: |
curl --fail --location --silent --show-error \
--output /tmp/grpcurl.tgz \
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
| sha256sum --check
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
- name: Build and test
run: >-
mvn -B --settings .github/settings.xml
-Dh2spec.executable=/tmp/h2spec
-Dcurl.executable=/usr/bin/curl
-Dnghttp.executable=/usr/bin/nghttp
-Dgrpcurl.executable=/tmp/grpcurl
-Djdk.tracePinnedThreads=full
-Pjmh
-Dflash.performance.gates=true
clean verify
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: '**/target/surefire-reports/*.xml'
retention-days: 7
-348
View File
@@ -1,348 +0,0 @@
name: Publish Docs
on:
workflow_dispatch:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
workflow_call:
inputs:
version:
description: 'Docs version to publish (e.g. 2.1.0)'
required: true
type: string
secrets:
MAVEN_USERNAME:
required: true
MAVEN_PASSWORD:
required: true
jobs:
docs:
name: Build JavaDoc & Update gh-pages
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Build project (compile + resolve deps, skip tests)
run: mvn -B --settings .github/settings.xml clean verify -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
# javadoc:aggregate runs on the reactor root.
# With flash + flash-extensions both declared as modules in root pom.xml,
# this produces a single aggregated Javadoc covering all modules.
# Default output path: target/site/apidocs/ (no custom reportOutputDirectory set).
- name: Generate aggregated JavaDoc
run: |
mvn -B \
--settings .github/settings.xml \
-DskipTests \
org.apache.maven.plugins:maven-javadoc-plugin:3.6.3:aggregate
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Verify JavaDoc output exists
run: |
APIDOCS=""
if [ -f "target/site/apidocs/index.html" ]; then
APIDOCS="target/site/apidocs"
elif [ -f "target/reports/apidocs/index.html" ]; then
APIDOCS="target/reports/apidocs"
else
echo "ERROR: JavaDoc output not found."
echo
echo "Contents of target/:"
find target -maxdepth 5 2>/dev/null || echo "(empty)"
exit 1
fi
echo "APIDOCS_DIR=$APIDOCS" >> $GITHUB_ENV
COUNT=$(find "$APIDOCS" -name '*.html' | wc -l)
echo "JavaDoc OK — $COUNT HTML files at $APIDOCS"
- name: Checkout gh-pages
uses: actions/checkout@v4
with:
ref: gh-pages
path: gh-pages-out
token: ${{ secrets.GITHUB_TOKEN }}
- name: Copy JavaDoc to versioned folder and latest
run: |
VERSION=${{ inputs.version }}
mkdir -p gh-pages-out/javadoc/$VERSION
cp -r "$APIDOCS_DIR"/. gh-pages-out/javadoc/$VERSION/
rm -rf gh-pages-out/latest
mkdir -p gh-pages-out/latest
cp -r "$APIDOCS_DIR"/. gh-pages-out/latest/
- name: Regenerate index.html
run: |
cd gh-pages-out
python3 - <<'EOF'
import os, re
def version_key(v):
parts = re.findall(r'\d+', v)
return [int(p) for p in parts] if parts else [0]
versions = sorted(
[d for d in os.listdir("javadoc") if os.path.isdir(f"javadoc/{d}")],
key=version_key,
reverse=True
)
latest = versions[0] if versions else None
rows = "\n".join(
f'''
<div class="release">
<div class="release-info">
<span class="version">{v}</span>
{"<span class='badge'>latest</span>" if v == latest else ""}
</div>
<a href="javadoc/{v}/index.html">Open</a>
</div>
'''
for v in versions
)
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flash — JavaDoc</title>
<style>
:root {
--bg: #ffffff;
--surface: #fafafa;
--border: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--accent: #111827;
--accent-hover: #000000;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 64px 24px;
}
.container {
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.header {
margin-bottom: 40px;
}
.header h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.04em;
}
.header p {
margin-top: 10px;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.6;
}
.latest {
display: inline-flex;
align-items: center;
margin-top: 20px;
padding-bottom: 2px;
color: var(--accent);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
border-bottom: 1px solid transparent;
transition:
border-color 0.15s ease,
color 0.15s ease;
}
.latest:hover {
border-color: var(--accent);
color: var(--accent-hover);
}
.list {
border-top: 1px solid var(--border);
}
.release {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 0;
border-bottom: 1px solid var(--border);
}
.release-info {
display: flex;
align-items: center;
gap: 12px;
}
.version {
font-size: 0.96rem;
font-weight: 500;
letter-spacing: -0.01em;
}
.badge {
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
border: 1px solid var(--border);
padding: 2px 8px;
}
.release a {
color: var(--accent);
text-decoration: none;
font-size: 0.92rem;
}
.release a:hover {
text-decoration: underline;
}
.empty {
padding: 32px 0;
color: var(--muted);
font-size: 0.95rem;
}
@media (max-width: 640px) {
body {
padding: 40px 20px;
}
.header h1 {
font-size: 1.7rem;
}
.release {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>Flash JavaDoc</h1>
<p>
API documentation for all published Flash releases.
</p>
""" + (
f'''
<a class="latest" href="latest/index.html">
Latest release — {latest}
</a>
'''
if latest else ""
) + """
</header>
""" + (
f'''
<div class="list">
{rows}
</div>
'''
if rows else
'''
<div class="empty">
No versions published yet.
</div>
'''
) + """
</div>
</body>
</html>"""
with open("index.html", "w", encoding="utf-8") as f:
f.write(html)
print(f"index.html generated — {len(versions)} version(s): {versions}")
EOF
- name: Push gh-pages
run: |
cd gh-pages-out
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "docs(javadoc): publish ${{ inputs.version }}"
git push origin gh-pages
-69
View File
@@ -1,69 +0,0 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 2.0.0)'
required: true
type: string
next_version:
description: 'Next development version without -SNAPSHOT (e.g. 2.1.0)'
required: true
type: string
jobs:
prepare:
name: Bump, Tag & Push
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Set release version
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.version }}
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Commit release version
run: |
git add -A
git commit -m "chore(release): ${{ inputs.version }}"
- name: Tag release
run: git tag v${{ inputs.version }}
- name: Set next snapshot version
run: mvn -B --settings .github/settings.xml versions:set -DnewVersion=${{ inputs.next_version }}-SNAPSHOT
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Commit next snapshot version
run: |
git add -A
git commit -m "chore(release): prepare ${{ inputs.next_version }}-SNAPSHOT"
- name: Push commits and tag
run: |
git push origin master
git push origin v${{ inputs.version }}
-70
View File
@@ -1,70 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
# ── 1. Build, GPG-sign, deploy to Maven releases ──────────────────────────
release:
name: Build, Sign & Deploy
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Import GPG key
run: |
echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | grep '^sec' | cut -d: -f5 | head -1)
echo "GPG_KEY_ID=$GPG_KEY_ID" >> $GITHUB_ENV
- name: Build, sign and deploy to releases
run: |
mvn -B --settings .github/settings.xml \
-DperformRelease=true \
-Dgpg.keyname=$GPG_KEY_ID \
clean deploy
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
draft: false
prerelease: false
# ── 2. Publish JavaDoc (single source of truth: docs.yml) ─────────────────
docs:
name: Publish JavaDoc
needs: release
uses: ./.github/workflows/docs.yml
with:
version: ${{ needs.release.outputs.version }}
secrets:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
-30
View File
@@ -1,30 +0,0 @@
name: Publish Snapshot
on:
push:
branches:
- master
tags-ignore:
- 'v*'
jobs:
snapshot:
name: Deploy Snapshot
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Temurin 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven
- name: Deploy snapshot
run: mvn -B --settings .github/settings.xml clean deploy -DskipTests
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
-57
View File
@@ -1,57 +0,0 @@
target/
*.versionsBackup
.mvn/timing.properties
*.class
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
.kotlin
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
.idea/workspace.xml
.idea/inspectionProfiles/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
CLAUDE.md
.claude/
### Local / scratch ###
flash/src/main/java/dev/relism/Main.java
flash-bench/
nuxt-shadcn-dashboard/
/dev/
/docs/
jmh-result.text
*.text
/flash-extensions/flash-ext-routeviewer/routeviewer-ui/node_modules/
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-43
View File
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/flash-bench/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-bench/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-hibernate/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data-jdbc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-data/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-jte/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<excludedPredefinedLibrary name="Flash/nuxt-shadcn-dashboard/node_modules" />
</component>
</project>
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<writeAnnotations>
<writeAnnotation name="lombok.Getter" />
</writeAnnotations>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</list>
</option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/flash-bench/pom.xml" />
</set>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-480
View File
@@ -1,480 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="CopilotChats">
<option name="panelChat">
<chat>
<option name="activeSessionId" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<option name="sessions">
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="a8d77f48-1eb9-4c5a-961c-1e1452a25ca0" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="status" value="Completed" />
<option name="targetType" value="LOCAL" />
</session>
<session>
<option name="chatType" value="PANEL" />
<option name="id" value="82f48e09-2ca4-4a27-9a04-c65d7a5f0b88" />
<option name="modeId" value="Agent" />
<option name="modelType" value="builtin_family" />
<option name="modelValue" value="auto" />
<option name="targetType" value="LOCAL" />
</session>
</option>
<option name="type" value="PANEL" />
</chat>
</option>
</component>
<component name="CopilotPersistence">
<persistenceIdMap>
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/Flash" value="3Axc6mzLyNvh4TgGFvNSCSq83xw" />
<entry key="_C:/Users/elorc/Documents/Coding/Java/practice/FlashPractice" value="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
<entry key="_C:/Users/relis/Documents/coding/personale/Java/Flash5" value="3CTWwiHEXMdUOOMmV3iz3FrRc6h" />
</persistenceIdMap>
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="448" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="Enum" />
<option value="Interface" />
<option value="Class" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="ROOT_SYNC" value="DONT_SYNC" />
</component>
<component name="GitHubPullRequestSearchHistory">{
&quot;lastFilter&quot;: {
&quot;state&quot;: &quot;OPEN&quot;,
&quot;assignee&quot;: &quot;Relism&quot;
}
}</component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/Relism/Flash5.git&quot;,
&quot;accountId&quot;: &quot;86d8a39c-af27-4b79-8d5d-dc74375c3348&quot;
}
}</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="ProjectErrors" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 3
}</component>
<component name="ProjectId" id="3AoiAx4zfxMcuO6fDI9oALOPcq1" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;Application.(dev) flash-bench.executor&quot;: &quot;Run&quot;,
&quot;Application.ExternalBenchmark (1).executor&quot;: &quot;Run&quot;,
&quot;Application.ExternalBenchmark.executor&quot;: &quot;Run&quot;,
&quot;Application.Main.executor&quot;: &quot;Run&quot;,
&quot;Application.MainAlt.executor&quot;: &quot;Run&quot;,
&quot;Application.dev.relism.bench.Main.executor&quot;: &quot;Run&quot;,
&quot;JUnit.RequestParserTest.executor&quot;: &quot;Run&quot;,
&quot;JUnit.RequestParserTest.headers_caseInsensitive.executor&quot;: &quot;Debug&quot;,
&quot;Maven.FlashPractice [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [compile].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [clean].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-bench [validate].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-limiter [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-ext-view [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [clean].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [compile].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [deploy].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [install].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [package].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [test].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [validate].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-parent [verify].executor&quot;: &quot;Run&quot;,
&quot;Maven.flash-web-bundler [test].executor&quot;: &quot;Run&quot;,
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;onboarding.tips.debug.path&quot;: &quot;C:/Users/elorc/Documents/Coding/Java/practice/Flash/flash-extensions/flash-ext-data/src/main/java/dev/relism/Main.java&quot;,
&quot;project.structure.last.edited&quot;: &quot;Modules&quot;,
&quot;project.structure.proportion&quot;: &quot;0.15&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.1150748&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;project.propVCSSupport.DirectoryMappings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\elorc\\Documents\\Coding\\Java\\practice\\Flash\\nuxt-shadcn-dashboard\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\elorc\Documents\Coding\Java\practice\Flash" />
</key>
<key name="MoveClassesOrPackagesDialog.RECENTS_KEY">
<recent name="dev.relism" />
</key>
</component>
<component name="RunManager" selected="Application.MainAlt">
<configuration name="ExternalBenchmark" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.ExternalBenchmark" />
<module name="flash" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="MainAlt" type="Application" factoryName="Application" temporary="true" nameIsGenerated="true">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.MainAlt" />
<module name="flash-bench" />
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="(dev) flash-bench" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="dev.relism.bench.Main" />
<module name="flash-bench" />
<option name="VM_PARAMETERS" value="-Dflash.env=dev" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.relism.bench.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<list>
<item itemvalue="Application.(dev) flash-bench" />
<item itemvalue="Application.ExternalBenchmark" />
<item itemvalue="Application.MainAlt" />
</list>
<recent_temporary>
<list>
<item itemvalue="Application.MainAlt" />
</list>
</recent_temporary>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-30f59d01ecdd-2fc7cc6b9a17-intellij.indexing.shared.core-IU-253.30387.90" />
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-IU-253.30387.90" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="fc757130-fe3e-4ea9-8d69-c26ffb8545a4" name="Changes" comment="" />
<created>1773265186049</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1773265186049</updated>
<workItem from="1773265187409" duration="5496000" />
<workItem from="1773313172181" duration="9617000" />
<workItem from="1773325837582" duration="3918000" />
<workItem from="1773400995673" duration="23805000" />
<workItem from="1773442006686" duration="2722000" />
<workItem from="1773491408276" duration="23095000" />
<workItem from="1773528595525" duration="7400000" />
<workItem from="1773536222450" duration="680000" />
<workItem from="1773536904928" duration="395000" />
<workItem from="1773537302622" duration="150000" />
<workItem from="1773537489622" duration="5048000" />
<workItem from="1773576241900" duration="22639000" />
<workItem from="1773613172976" duration="13157000" />
<workItem from="1773668722120" duration="389000" />
<workItem from="1773680650110" duration="4711000" />
<workItem from="1773701882230" duration="6975000" />
<workItem from="1773744806275" duration="48612000" />
<workItem from="1773831300464" duration="1538000" />
<workItem from="1773837116508" duration="4255000" />
<workItem from="1773847734658" duration="911000" />
<workItem from="1773875652058" duration="5481000" />
<workItem from="1773915392332" duration="36877000" />
<workItem from="1773996650761" duration="101000" />
<workItem from="1774011413085" duration="2519000" />
<workItem from="1774033964550" duration="5379000" />
<workItem from="1774116033963" duration="1364000" />
<workItem from="1774136286683" duration="3816000" />
<workItem from="1774187096932" duration="25000" />
<workItem from="1774458099414" duration="14351000" />
<workItem from="1774523863906" duration="9762000" />
<workItem from="1774555172612" duration="9776000" />
<workItem from="1774604979874" duration="16474000" />
<workItem from="1774628513273" duration="86000" />
<workItem from="1774638461244" duration="5718000" />
<workItem from="1774691772785" duration="3801000" />
<workItem from="1774703412987" duration="25271000" />
<workItem from="1774777423667" duration="2127000" />
<workItem from="1774790933314" duration="10099000" />
<workItem from="1774814271256" duration="6223000" />
<workItem from="1774872087063" duration="23188000" />
<workItem from="1774944423363" duration="2161000" />
<workItem from="1774953226940" duration="4167000" />
<workItem from="1775295922142" duration="5680000" />
<workItem from="1775312662640" duration="1858000" />
<workItem from="1775391482685" duration="1961000" />
<workItem from="1775495346638" duration="596000" />
<workItem from="1775747075572" duration="1237000" />
<workItem from="1776192895352" duration="3823000" />
<workItem from="1776198526994" duration="4187000" />
<workItem from="1776240695245" duration="14368000" />
<workItem from="1776410282788" duration="22340000" />
<workItem from="1776515579524" duration="1172000" />
<workItem from="1776626614705" duration="4066000" />
<workItem from="1776670293634" duration="9127000" />
<workItem from="1776716089200" duration="2010000" />
<workItem from="1776872699811" duration="4161000" />
<workItem from="1776931805422" duration="16122000" />
<workItem from="1777051880577" duration="2650000" />
<workItem from="1777150747725" duration="837000" />
<workItem from="1777198150359" duration="5628000" />
<workItem from="1777451402985" duration="709000" />
<workItem from="1777534738187" duration="13411000" />
<workItem from="1777970837797" duration="5042000" />
<workItem from="1778160998498" duration="2931000" />
<workItem from="1778319397472" duration="5040000" />
<workItem from="1778352978922" duration="2169000" />
<workItem from="1778414714349" duration="23000" />
<workItem from="1778417425077" duration="3241000" />
<workItem from="1778489168036" duration="9828000" />
<workItem from="1778576795735" duration="5051000" />
</task>
<task id="LOCAL-00001" summary="Initial">
<option name="closed" value="true" />
<created>1773536078762</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1773536078762</updated>
</task>
<task id="LOCAL-00002" summary="pre-module refactor">
<option name="closed" value="true" />
<created>1773536678416</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1773536678416</updated>
</task>
<task id="LOCAL-00003" summary="refactored, pre-buffer reuse">
<option name="closed" value="true" />
<created>1773581516834</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1773581516834</updated>
</task>
<task id="LOCAL-00004" summary="optimized dynamic body size impl, decluttering javadocs/comments">
<option name="closed" value="true" />
<created>1773591259108</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1773591259108</updated>
</task>
<task id="LOCAL-00005" summary="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles">
<option name="closed" value="true" />
<created>1773920675327</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1773920675327</updated>
</task>
<task id="LOCAL-00006" summary="multipart parsing, request body access, and chunked input stream support">
<option name="closed" value="true" />
<created>1773920735659</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1773920735659</updated>
</task>
<task id="LOCAL-00007" summary="enhanced router middleware support; added pre-fused middleware handling and improved handler registration">
<option name="closed" value="true" />
<created>1773952556190</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1773952556190</updated>
</task>
<task id="LOCAL-00008" summary="pre-major refactoring + ext api.">
<option name="closed" value="true" />
<created>1774530802482</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1774530802482</updated>
</task>
<task id="LOCAL-00009" summary="preparing for a conceptual refactoring...">
<option name="closed" value="true" />
<created>1774703474044</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1774703474044</updated>
</task>
<task id="LOCAL-00010" summary="preparing for another refactoring...">
<option name="closed" value="true" />
<created>1774819003821</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1774819003821</updated>
</task>
<task id="LOCAL-00011" summary="implement OpenAPI contributor integration for rate limiting and response headers">
<option name="closed" value="true" />
<created>1776634972916</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1776634972916</updated>
</task>
<task id="LOCAL-00012" summary="add core view extension with JTE and Thymeleaf support">
<option name="closed" value="true" />
<created>1776724331443</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1776724331443</updated>
</task>
<task id="LOCAL-00013" summary="refactor: rename packages and files to use 'flash' prefix for consistency">
<option name="closed" value="true" />
<created>1777199691803</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1777199691804</updated>
</task>
<task id="LOCAL-00014" summary="fix: enhance global key validation and update template parameters">
<option name="closed" value="true" />
<created>1777451475753</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1777451475753</updated>
</task>
<task id="LOCAL-00015" summary="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements">
<option name="closed" value="true" />
<created>1778508899541</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1778508899541</updated>
</task>
<option name="localTasksCounter" value="16" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="claude/distracted-spence" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initial" />
<MESSAGE value="pre-module refactor" />
<MESSAGE value="refactored, pre-buffer reuse" />
<MESSAGE value="optimized dynamic body size impl, decluttering javadocs/comments" />
<MESSAGE value="enhanced HTTP server configuration and response handling; added acceptorThreads, improved header management, and refined error page titles" />
<MESSAGE value="multipart parsing, request body access, and chunked input stream support" />
<MESSAGE value="enhanced router middleware support; added pre-fused middleware handling and improved handler registration" />
<MESSAGE value="pre-major refactoring + ext api." />
<MESSAGE value="preparing for a conceptual refactoring..." />
<MESSAGE value="preparing for another refactoring..." />
<MESSAGE value="implement OpenAPI contributor integration for rate limiting and response headers" />
<MESSAGE value="add core view extension with JTE and Thymeleaf support" />
<MESSAGE value="refactor: rename packages and files to use 'flash' prefix for consistency" />
<MESSAGE value="fix: enhance global key validation and update template parameters" />
<MESSAGE value="chore(release): prepare 2.1.0-SNAPSHOT" />
<MESSAGE value="feat: introduce Spec and Query interfaces with transaction propagation enhancements" />
<MESSAGE value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
<option name="LAST_COMMIT_MESSAGE" value="feat: introduce WebSocket support with new endpoints and transaction propagation enhancements" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>
-122
View File
@@ -1,122 +0,0 @@
# Flash — Agent Guidelines
This document defines the conventions and rules that all agents (AI or human) must follow
when working on this repository. Read it entirely before making any change.
---
## Git Workflow
### Branch Naming
| Type | Pattern | Example |
|------|---------|---------|
| New feature | `feature/<scope>/<short-description>` | `feature/ext-oidc/pkce-support` |
| Bug fix | `fix/<scope>/<short-description>` | `fix/core/router-npe` |
| Hotfix on released version | `hotfix/<version>/<short-description>` | `hotfix/2.0.1/auth-bypass` |
| CI/infra changes | `feature/ci/<short-description>` | `feature/ci/add-snapshot-workflow` |
Rules:
- Always branch from `master`.
- Branch names are lowercase, words separated by `-`.
- Never push directly to `master`.
- Never create `develop`, `release/*`, or any other long-lived branch.
### Commit Messages — Conventional Commits
Format: `<type>(<scope>): <short description>`
| Type | When to use |
|------|-------------|
| `feat` | New feature |
| `fix` | Bug fix |
| `refactor` | Code change without feature/fix |
| `test` | Adding or updating tests |
| `docs` | Documentation only |
| `chore` | Build, deps, tooling — no production code |
| `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`,
`ext-data-hibernate`, `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
Examples:
```
feat(ext-oidc): add PKCE support
fix(core): fix NPE in RouteHandler when path is null
chore(deps): upgrade jackson to 2.18.0
ci: add timeout to snapshot workflow
chore(release): 2.1.0
```
### Pull Requests
- Every branch must be merged via PR, never with a direct push.
- PR title must follow Conventional Commits format.
- CI (`ci.yml`) must be green before merging.
- Squash merge is preferred for `feature/*` and `fix/*` to keep history clean.
- Merge commit is preferred for hotfixes (preserves the fix commit intact).
---
## Versioning
- All modules share a single version defined in the root `pom.xml` (`flash-parent`).
- Never change the version in child POMs — always and only in the parent.
- Current scheme: `MAJOR.MINOR.PATCH`
- MAJOR: breaking API changes
- MINOR: new backward-compatible features
- PATCH: backward-compatible bug fixes on an already-released version
- During development, master always carries a `-SNAPSHOT` version.
- **Never manually edit the version** — versions are bumped exclusively by the
`prepare-release` GitHub Actions workflow.
### Release Process (for maintainers only)
1. Ensure `master` is green (CI passing).
2. Go to GitHub Actions → `Prepare Release``Run workflow`.
3. Input `version` (e.g. `2.1.0`) and `next_version` (e.g. `2.2.0`).
4. The workflow handles everything: bump, commit, tag, push.
5. The `release` workflow then triggers automatically on the tag.
---
## Maven & Module Structure
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
- `flash` module: the core framework JAR.
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
under `flash-extensions/` — it is not something you `install()`, and it carries
`junit-jupiter-api` at compile scope.
- `flash-extensions` POM: aggregator for all extension modules.
- Extensions live under `flash-extensions/flash-ext-*/`.
- When adding a new extension:
1. Add the module to `flash-extensions/pom.xml` `<modules>`.
2. Add the dependency to `flash-extensions/pom.xml` `<dependencyManagement>`.
3. Add the dependency to the root `pom.xml` `<dependencyManagement>`.
4. Do **not** declare a `<version>` in the new module's POM — it inherits from the parent.
---
## CI/CD Pipelines
| Workflow | Trigger | What it does |
|----------|---------|--------------|
| `ci.yml` | Push to any branch, PR to master | Compile + test — required gate |
| `snapshot.yml` | Push to `master` | Deploy `-SNAPSHOT` to `maven.relism.dev/snapshots` |
| `prepare-release.yml` | Manual `workflow_dispatch` | Bump version, commit, tag, push |
| `release.yml` | Push of tag `v*` | GPG sign, deploy to `/releases`, JavaDoc to GitHub Pages, GitHub Release |
**Agents must never manually trigger `prepare-release` or modify version strings.**
---
## What Agents Must NOT Do
- Push directly to `master` or `gh-pages`.
- Manually edit `<version>` tags in any POM.
- Add new `<repositories>` or `<distributionManagement>` entries without explicit instruction.
- Modify `.github/workflows/*.yml` files without explicit instruction.
- Commit generated files (`target/`, `*.class`, `*.versionsBackup`).
- Use `git push --force` on any branch.
+1
View File
@@ -0,0 +1 @@
flash.javadocs.relism.dev
-537
View File
@@ -1,537 +0,0 @@
# Flash
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
## Modules
| Module | Description |
|---|---|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-auth-core` | Authentication seam + role/scope authorization |
| `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow |
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-oidc |
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
## Requirements
- Java 21+
- Maven 3.8+
## Quick start
```java
FlashApp.create(8080)
.get("/ping", (req, res) -> "pong")
.start();
```
With full configuration:
```java
FlashApp.create(
FlashConfiguration.builder()
.port(8080)
.host("0.0.0.0")
.maxHeaderBufferSize(65536)
.build()
)
.get("/ping", (req, res) -> "pong")
.start();
```
## Route registration
### Lambda routes
```java
FlashApp app = FlashApp.create(8080);
app.get("/hello", (req, res) -> "world");
app.post("/echo", (req, res) -> {
byte[] body = req.body().bytes();
return res.status(200).body(body);
});
app.get("/users/{id}", (req, res) -> {
String id = req.param("id");
return "user:" + id;
});
```
### Class-based handlers
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
`onInit()` after Flash has resolved its complete boot-time service graph:
```java
@GET("/api/users")
public class ListUsers extends RequestHandler {
private UserService users;
@Override protected void onInit() { users = require(UserService.class); }
@Override public Object handle(Request req, Response res) { return users.list(); }
}
app.scan("dev.example.api");
```
### Middleware
Apply middleware at registration. Flash composes the final chain at boot:
```java
Middleware authCheck = next -> (req, res) -> {
if (req.header("Authorization") == null)
return res.status(401).body("Unauthorized");
return next.handle(req, res);
};
app.get("/secure", (req, res) -> "secret data", authCheck);
```
Multiple middlewares are composed outermost-first (left-to-right in the call):
```java
app.get("/admin", handler, logging, auth, rateLimit);
// execution order: logging → auth → rateLimit → handler
```
### Classpath scan
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
instantiated via its public no-arg constructor:
```java
app.scan("dev.example.handlers");
```
### Namespace mounting
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
prefix prepended automatically. The scope inherits the parent's extension context (annotation
processors, services):
```java
app.mount("/api", scope -> {
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
scope.scan("dev.example.api");
});
```
## Extensions
Extensions have one declarative `configure` method. They declare services, processors and route
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
opens listeners. Extension install order never makes a service “not ready”.
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.install(new OidcExtension(oidcConfig))
.scan("dev.example.handlers")
.start();
```
See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
- [`flash-ext-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md)
- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/docs/README.md)
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
- [`flash-testing`](flash-testing/docs/README.md)
## Error handlers
```java
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
app.onException((ex, req, res) -> {
if (ex instanceof IllegalArgumentException)
return res.status(400).body(ex.getMessage());
return res.status(500).body("Internal error");
});
```
## FlashConfiguration
| Field | Default | Description |
|---|---|---|
| `port` | — | TCP port to bind |
| `host` | `"0.0.0.0"` | Bind address |
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
## Protocols
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
HTTP/1.1.
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
still requires the normal certificate configuration shown below.
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
## WebSockets over HTTP/2
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
flow-controlled DATA frames. No alternate handler, route, or session API is required:
```java
app.ws("/live", handler);
```
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
fragmentation, close, and callback behavior on both transports. Client support for negotiating
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
## TLS
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
### Quick start
```java
FlashApp.create(FlashConfiguration.builder()
.port(443)
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
.build())
.get("/ping", (req, res) -> "pong") // HTTPS
.ws("/live", handler) // WSS, same route API
.start();
```
### Multiple listeners
One app can bind any number of ports, each independently plain or TLS:
```java
FlashApp.create(FlashConfiguration.builder()
.listener(new FlashConfiguration.Listener(80)) // plain
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
.build());
```
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
are shared by all of them — one app, N ports.
### `TlsConfig`
| Factory | Use |
|---|---|
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
Chainable on either factory:
```java
TlsConfig.keystore(cert, pass)
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
```
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
per-hostname config. The first entry in the keystore is the default when SNI is absent or
matches nothing (same convention as nginx/HAProxy's `default_server`).
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
can therefore read `engine.getHandshakeApplicationProtocol()` (or
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
then, so the certificate decision can key off it.
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
### Reading TLS info from a request
```java
app.get("/whoami", (req, res) -> {
if (!req.isSecure()) return "plain";
SSLSession session = req.sslSession(); // null iff !isSecure()
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
return session.getCipherSuite() + " / " + session.getProtocol();
});
```
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
that got the request this far has already completed, never a forced handshake.
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
upgrading `Request` — no separate TLS state is tracked for WS.
## Object lifetime
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
created per connection and repositioned (`reset()`) over each new request/response in turn — the
same idiom Java NIO buffers use, applied to the whole request/response model
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
request/response cycle 0 B/op.
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read
*after* the handler returns will observe whatever the *next* request on that connection
repositioned the same instance to — not the request you thought you had:
```java
// WRONG — captures `req`, reads it after the handler has returned
app.get("/slow", (req, res) -> {
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
return "ok";
});
```
Copy out whatever you need before returning or handing work off asynchronously — every accessor
that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy
that's safe to keep as long as you like:
```java
app.get("/slow", (req, res) -> {
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
CompletableFuture.runAsync(() -> log(traceId));
return "ok";
});
```
Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately
at the offending call site instead of silently reading the wrong request's data — turn this on in
tests and local development. It's a no-op in production beyond a single `boolean` field read.
`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume
(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later.
### Reusable response headers
Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value
once and remains valid on both HTTP versions:
```java
private static final PreEncodedHeader NO_STORE =
new PreEncodedHeader("cache-control", "no-store");
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
```
`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
application and middleware code.
### Trailers and push streaming
Request trailers become available after the body reaches EOF:
```java
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
```
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
```java
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension.
## Testing
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
a client pointed at it. Add it with test scope:
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<version>${flash.version}</version>
<scope>test</scope>
</dependency>
```
```java
class UserRoutesTest {
@RegisterExtension
static FlashTest app = FlashTest.of(new BlogApp())
.mock(UserService.class, new InMemoryUserService());
@Test
void listsUsers() {
app.get("/api/users")
.expectStatus(200)
.expectHeader("content-type", "application/json")
.expectBodyContains("alice");
}
}
```
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
independently of which port they run on:
```java
public final class BlogApp implements FlashApplication {
@Override public void configure(FlashApp app) {
app.install(new JacksonExtension());
app.mount("/api", scope -> scope.scan("dev.blog.api"));
}
}
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
```
It is a functional interface, so a lambda works too:
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
### Requests
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
failure. `get` and `delete` skip the builder when there is nothing to add.
```java
app.get("/api/users").expectStatus(200);
app.request()
.header("Authorization", "Bearer " + token)
.json("{\"name\":\"bob\"}")
.post("/api/users")
.expectStatus(201);
try (FlashWebSocket socket = app.ws("/live")) {
socket.sendText("hello");
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
}
```
### Replacing services
`mock` installs replacements after everything your app and its extensions declare, so a fake always
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
a Mockito mock are equally welcome.
### More than one server
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
— so declaration order does the wiring:
```java
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
```
### Scope
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
### Configuration
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
you a client for.
```java
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
```
## Architecture
```
TransportFactory.create() # binds every listener, wires the connection runner
→ AcceptLoop # one per listener × accept thread; hands sockets off
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ RequestHandler.handle() # the same protocol-neutral request/response API
```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
## Build & test
```bash
# Build all modules (skip tests)
mvn clean package -DskipTests
# Run all tests
mvn test
# Run a single test class
mvn test -pl flash -Dtest=RequestParserTest
```
-24
View File
@@ -1,24 +0,0 @@
config:
target: "ws://localhost:8080/echo"
engines:
ws: {}
phases:
- duration: 30
arrivalRate: 50
rampTo: 500
name: "Riscaldamento progressivo"
- duration: 120
arrivalRate: 1000 # 1000 nuovi utenti al secondo
name: "Carico Estremo"
ensure:
maxErrorRate: 5
p99: 150
scenarios:
- name: "Saturazione Totale"
engine: ws
flow:
- loop:
- send: "Benchmark data"
# Rimosso il 'think' per eliminare il limite artificiale di 10msg/s per utente
count: 100 # Ogni utente spara a raffica 100 messaggi senza pause
@@ -1,105 +0,0 @@
# flash-ext-auth-core
Authorization, and the plumbing that carries a caller's identity through a request. It does not
know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the
OpenID Connect one.
The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and
`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it.
## The model
```
request ──► CredentialSource.authenticate(req, res) ──► claims
ClaimsHolder.set (this module only)
AuthMiddleware matches roles / scopes
handler
```
| Type | What it is |
|---|---|
| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. |
| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. |
| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. |
| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. |
| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. |
| `Session`, `SessionStore` | Server-side sessions for sources that keep them. |
Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware
publishes them, so no code can put claims on a request that did not carry them.
## Using it
You rarely install this module directly — an extension that contributes a source does it for you:
```java
// inside your extension's configure(...)
AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath("realm_access.roles")
.scopeClaimPaths("scope,scp")
.build(), mySource);
```
`install` publishes the middleware in the context and registers the annotation processor, so every
scanned handler carrying an auth annotation is mounted behind it. See
[`credential-sources.md`](credential-sources.md) to write a source of your own.
On lambda routes, take the middleware out of the context:
```java
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
app.get("/", homeHandler, auth.optional());
app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin"));
app.post("/orders", createOrder, auth.requireScopes("orders:write"));
```
## Annotations
On a scanned handler class, and mounted automatically:
| Annotation | Effect |
|---|---|
| `@Authenticated` | Any accepted credential. No role check. |
| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. |
| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. |
| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. |
| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. |
`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for
a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather
than at 3am.
## Where roles and scopes are read from
`AuthConfig` names the claim paths, because every provider spells them differently:
| | Default | Common alternatives |
|---|---|---|
| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) |
| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` |
Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in
order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work.
Matching is deliberate about a distinction that bites otherwise:
- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two
scopes;
- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named
`a b`, not two.
Prefix matches never count: `administrator` does not satisfy `admin`.
## Ordering around authentication
`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension
contributing its own middleware can order itself against it:
```java
MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY);
```
@@ -1,95 +0,0 @@
# Writing a credential source
A `CredentialSource` is the only thing that stands between a request and its claims. Everything
else in this module — annotations, policy, matching, the holder — works the same regardless of
which one is installed.
```java
public interface CredentialSource {
Map<String, Object> authenticate(Request req, Response res);
Map<String, Object> peek(Request req);
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
}
```
## `authenticate` has three outcomes, and the last two are not the same
| Return | Means | The middleware then |
|---|---|---|
| claims | a valid credential was presented | publishes them and calls the handler |
| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more |
| throws `HttpException` | a credential **was** presented and is invalid | propagates it |
Flattening the last two is the single easiest way to get this wrong. "No session, send the browser
to the sign-in page" and "this token is forged" are different answers, and a caller can tell:
the first is a `302` to a login screen, the second a `401` the client must not retry blindly.
A source that returns `null` owns the response by then — it has redirected, or written a `401` with
its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before*
throwing, because the exception unwinds past the middleware.
`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null`
when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller
is a normal outcome. Never make `peek` refresh state that `authenticate` would not have.
## A minimal source
```java
public final class ApiKeySource implements CredentialSource {
private final Map<String, Map<String, Object>> keys; // key -> claims
@Override
public Map<String, Object> authenticate(Request req, Response res) {
String key = req.header("X-Api-Key");
if (key == null) {
res.header("WWW-Authenticate", "ApiKey realm=\"api\"");
throw HttpException.unauthorized();
}
Map<String, Object> claims = keys.get(key);
if (claims == null) throw HttpException.unauthorized(); // presented and wrong
return claims;
}
@Override
public Map<String, Object> peek(Request req) {
String key = req.header("X-Api-Key");
return key != null ? keys.get(key) : null;
}
}
```
This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so
"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what
the interface *allows*, not a checklist.
## Claims are yours to shape
The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys —
`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with
code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so
they can live under any key you like as long as the two agree.
## Installing it
```java
public final class ApiKeyExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(ApiKeySource.class, source);
AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath("roles")
.build(), source);
}
}
```
`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying
`@Authenticated` and friends are mounted behind your source with nothing further to do.
## One source at a time
`AuthMiddleware` is published in the context under its own type, so installing two extensions that
each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two
kinds of credential, that is one source that tries both, not two sources: the order they are tried
in, and what happens when the first rejects, are decisions that have to live somewhere explicit.
@@ -1,49 +0,0 @@
# Sessions
A `Session` is what a credential source keeps server-side between requests, looked up by a cookie.
Core owns the container; what goes in it is the source's business.
```java
public final class Session {
String id();
Map<String, Object> claims();
Instant expiresAt();
Map<String, Object> attributes();
boolean isExpired();
Object attribute(String key);
String attributeAsString(String key);
}
```
## Why it expires early
`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can
pass the check at the top of a request and be dead by the time the handler uses it — a class of
failure that reproduces once a day and never in a test. Renewal is therefore always slightly
premature, on purpose.
## Attributes
`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh
tokens there under its own keys, which is what lets renewal stay entirely inside that extension
while the session itself carries no OAuth2 vocabulary.
Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers
read `claims()`.
## The store
```java
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
```
`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it
loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a
deploy or be shared across nodes.
Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it
over the old — there is no mutate-in-place path, so a store can cache or serialise freely.
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-auth-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -1,43 +0,0 @@
package dev.relism.flash.ext.auth;
/**
* Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a
* credential belongs to the {@link CredentialSource} that produced it, and everything about
* *checking* one is right here.
*
* <p>Defaults are the generic spelling, not any one provider's. A source that knows better —
* {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds
* its own {@code AuthConfig} with the paths its provider actually uses.
*/
public final class AuthConfig {
private final String rolesClaimPath;
private final String scopeClaimPaths;
private AuthConfig(Builder b) {
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
}
/** Dot-separated path to the roles list in the claims (default: {@code roles}). */
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String rolesClaimPath = "roles";
private String scopeClaimPaths = "scope,scp";
private Builder() {}
/** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths scopes are read from, tried in order. */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
public AuthConfig build() { return new AuthConfig(this); }
}
}
@@ -1,309 +0,0 @@
package dev.relism.flash.ext.auth;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda
* routes, and injected automatically for handlers annotated with {@link Authenticated},
* {@link RolesAllowed} or {@link ScopesAllowed}.
*
* <p>It knows nothing about how the caller proved who they are — that is the
* {@link CredentialSource} it is built with. What lives here is the half that is the same for
* every mechanism: publish the claims for the request, match roles and scopes against them, clear
* up afterwards.
*
* <pre>{@code
* AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
* app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
* }</pre>
*/
public class AuthMiddleware {
/**
* Boot-time identity of the node annotation-driven authorization mounts under. Public so an
* extension that contributes its own middleware can order itself around authentication —
* {@code MiddlewareNode.of(...).afterIfPresent(AuthMiddleware.POLICY)}.
*/
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final AuthConfig config;
private final CredentialSource source;
private final String[] roleClaimPathParts;
private final String[][] scopeClaimPathParts;
public AuthMiddleware(AuthConfig config, CredentialSource source) {
this.config = config;
this.source = source;
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
/**
* Builds the middleware for {@code source}, publishes it in the context and registers the
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
* {@link ScopesAllowed} on scanned handlers.
*
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
*/
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
AuthMiddleware middleware = new AuthMiddleware(config, source);
ctx.provide(AuthMiddleware.class, middleware);
ctx.addAnnotationProcessor(handlerClass -> {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
return policy != null
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
: List.of();
});
return middleware;
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
public String rolesClaimPath() { return config.rolesClaimPath(); }
/** The source this middleware authenticates with. */
public CredentialSource source() { return source; }
/**
* The same authorization rules against a different credential source. Used where one route
* needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an
* OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps
* the plain one.
*/
public AuthMiddleware withSource(CredentialSource source) {
return new AuthMiddleware(config, source);
}
/**
* Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a
* challenge, a redirect into a sign-in flow — is the source's decision, not this one's.
*/
public Middleware protect() {
return next -> (req, res) -> {
Map<String, Object> claims = source.authenticate(req, res);
if (claims == null) return null; // the source already answered the request
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Publishes claims when the caller happens to be authenticated and never rejects anyone. Use
* it on public routes that personalise their response for signed-in callers.
*
* <pre>{@code
* app.get("/", handler, auth.optional());
* // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
* }</pre>
*/
public Middleware optional() {
return next -> (req, res) -> {
Map<String, Object> claims = source.peek(req);
if (claims != null) ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/**
* Applies a policy compiled once at boot from a handler's annotations. This is the path
* annotation-driven mounting takes.
*/
public Middleware authorize(AuthPolicy policy) {
if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map<String, Object> claims = source.authenticate(req, res);
if (claims == null) return null;
enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
} finally {
ClaimsHolder.clear();
}
};
}
/** {@link #protect()} plus at least one of the given roles (OR semantics). */
public Middleware requireRole(String... roles) {
return authorize(AuthPolicy.rolesAny(roles));
}
/** {@link #protect()} plus every one of the given scopes. */
public Middleware requireScopes(String... scopes) {
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
}
/** {@link #protect()} plus at least one of the given scopes. */
public Middleware requireAnyScope(String... scopes) {
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
}
// -- Policy enforcement ---------------------------------------------------
private void enforcePolicy(Map<String, Object> claims, AuthPolicy policy, Response res) {
checkRoles(claims, policy.requiredRoles());
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
}
private void checkRoles(Map<String, Object> claims, String[] required) {
if (required.length == 0) return;
if (rolesAllowed(claims, required)) return;
throw HttpException.forbidden();
}
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
Response res) {
if (required.length == 0) return;
if (scopesAllowed(claims, required, match)) return;
String challenge = source.insufficientScopeChallenge(required);
if (challenge != null) res.header("WWW-Authenticate", challenge);
throw HttpException.forbidden();
}
// -- Claim matching -------------------------------------------------------
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
Object actual = valueAtPath(claims, roleClaimPathParts);
if (actual == null) return false;
for (String role : required) {
if (containsToken(actual, role)) return true;
}
return false;
}
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
if (match == ScopesAllowed.Match.ALL) {
for (String scope : required) {
if (!hasScope(claims, scope)) return false;
}
return true;
}
for (String scope : required) {
if (hasScope(claims, scope)) return true;
}
return false;
}
private boolean hasScope(Map<String, Object> claims, String scope) {
for (String[] pathParts : scopeClaimPathParts) {
Object value = valueAtPath(claims, pathParts);
if (value != null && containsToken(value, scope)) return true;
}
return false;
}
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
Object current = claims;
for (String part : pathParts) {
if (!(current instanceof Map<?, ?> map)) return null;
current = map.get(part);
if (current == null) return null;
}
return current;
}
private static boolean containsToken(Object source, String token) {
if (source instanceof String s) return containsDelimitedToken(s, token);
if (source instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
if (source instanceof Object[] arr) {
for (Object item : arr) {
if (item == null) continue;
if (tokenEquals(item.toString(), token)) return true;
}
return false;
}
return tokenEquals(source.toString(), token);
}
private static boolean containsDelimitedToken(String value, String token) {
int len = value.length();
int i = 0;
while (i < len) {
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
int start = i;
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean tokenEquals(String value, String token) {
int start = 0;
int end = value.length();
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
}
private static boolean isScopeDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
private static String[] splitClaimPath(String path) {
if (path == null || path.isBlank()) {
throw new IllegalStateException("Claim path cannot be blank");
}
List<String> parts = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String p = path.substring(start, i).trim();
if (!p.isEmpty()) parts.add(p);
start = i + 1;
}
}
if (parts.isEmpty()) {
throw new IllegalStateException("Claim path cannot be blank");
}
return parts.toArray(String[]::new);
}
private static String[][] splitClaimPaths(String paths) {
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
start = i + 1;
}
}
if (out.isEmpty()) {
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
}
return out.toArray(String[][]::new);
}
}
@@ -1,98 +0,0 @@
package dev.relism.flash.ext.auth;
import java.util.LinkedHashSet;
import java.util.List;
/**
* Compiled authorization policy derived from handler annotations at mount time.
* Immutable and allocation-free on the request hot path.
*/
public final class AuthPolicy {
private static final String[] EMPTY = new String[0];
private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy(
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy(
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
private final boolean optionalAuth;
private final String[] requiredRoles;
private final String[] requiredScopes;
private final ScopesAllowed.Match scopeMatch;
private AuthPolicy(boolean optionalAuth,
String[] requiredRoles,
String[] requiredScopes,
ScopesAllowed.Match scopeMatch) {
this.optionalAuth = optionalAuth;
this.requiredRoles = requiredRoles;
this.requiredScopes = requiredScopes;
this.scopeMatch = scopeMatch;
}
public static AuthPolicy authenticated() { return AUTH_REQUIRED; }
public static AuthPolicy optional() { return AUTH_OPTIONAL; }
public static AuthPolicy rolesAny(String... roles) {
return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
}
public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
}
public static AuthPolicy compileFromAnnotations(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
boolean optionalAuth = auth != null && auth.optional();
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
+ handlerClass.getName());
}
return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
}
public static List<String> openApiScopesFor(Class<?> handlerClass) {
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
if (auth == null && roles == null && scopes == null) return null;
if (scopes == null) return List.of();
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
}
public boolean optionalAuth() { return optionalAuth; }
public String[] requiredRoles() { return requiredRoles; }
public String[] requiredScopes() { return requiredScopes; }
public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
private static String[] normalizeRequired(String annotation, String[] values) {
if (values == null || values.length == 0)
throw new IllegalStateException("@" + annotation + " requires at least one value");
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
for (String raw : values) {
if (raw == null) continue;
String trimmed = raw.trim();
if (!trimmed.isEmpty()) normalized.add(trimmed);
}
if (normalized.isEmpty())
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
return normalized.toArray(String[]::new);
}
}
@@ -1,40 +0,0 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a handler as requiring an authenticated caller. Any credential a registered source
* accepts is enough — no role or scope check is performed.
*
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
*
* <p>Set {@code optional = true} on public routes that personalise their response when the caller
* happens to be signed in but should remain reachable by guests. The middleware populates
* {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request
* is never rejected.
*
* <pre>{@code
* // Hard auth — 401 or a redirect when unauthenticated:
* @Route(method = HttpMethod.GET, path = "/api/profile")
* @Authenticated
* public class GetProfile extends JacksonHandler { ... }
*
* // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
* @Route(method = HttpMethod.GET, path = "/")
* @Authenticated(optional = true)
* public class HomePage extends HtmlHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Authenticated {
/**
* When {@code true} the middleware never rejects unauthenticated requests — it only
* populates {@link ClaimsHolder} when valid credentials are present.
* Defaults to {@code false} (hard authentication required).
*/
boolean optional() default false;
}
@@ -1,230 +0,0 @@
package dev.relism.flash.ext.auth;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
/**
* A typed view over one request's claims — whatever the {@link CredentialSource} that
* authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
*
* <p>The accessors name claim <em>keys</em>, not a protocol: {@code sub} is RFC 7519, and
* {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
* token issuer worth integrating. A source that uses different keys exposes them through
* {@link #claim(String)} or {@link #roles(String)}.
*
* <pre>{@code
* app.get("/api/whoami", (req, res) -> {
* Claims c = ClaimsHolder.current();
* return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
* }, auth.protect());
* }</pre>
*/
public final class Claims {
private final Map<String, Object> claims;
Claims(Map<String, Object> claims) {
this.claims = claims;
}
// ── Common claims ─────────────────────────────────────────────────────────
/** Subject identifier — the stable, unique id of the caller. */
public String sub() { return str("sub"); }
/** User's email address ({@code email} claim). */
public String email() { return str("email"); }
/** Human-readable username ({@code preferred_username} claim). */
public String username() { return str("preferred_username"); }
/** Full display name ({@code name} claim). */
public String name() { return str("name"); }
// ── Roles ────────────────────────────────────────────────────────────────
/**
* Extracts the roles list by traversing a dot-separated claim path.
*
* <p>Example paths:
* <ul>
* <li>{@code "realm_access.roles"} — Keycloak realm roles</li>
* <li>{@code "resource_access.my-client.roles"} — Keycloak client roles</li>
* <li>{@code "groups"} — Authelia / generic IdPs</li>
* </ul>
*
* @return list of role strings, or an empty list if the path doesn't exist
*/
@SuppressWarnings("unchecked")
public List<String> roles(String claimPath) {
String[] parts = claimPath.split("\\.");
Object current = claims;
for (String part : parts) {
if (!(current instanceof Map<?, ?> m)) return List.of();
current = m.get(part);
}
if (current instanceof List<?> list)
return list.stream().map(Object::toString).toList();
return List.of();
}
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
public boolean hasRole(String claimPath, String role) {
return roles(claimPath).contains(role);
}
// -- Scopes ---------------------------------------------------------------
/**
* Resolves scopes using the conventional fallback order:
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
*/
public List<String> scopes() {
return scopes("scope,scp");
}
/**
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
*/
public List<String> scopes(String claimPaths) {
List<String> out = new ArrayList<>();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s) {
appendDelimitedTokens(out, s);
continue;
}
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
String token = item.toString().trim();
if (!token.isEmpty()) out.add(token);
}
continue;
}
String token = value.toString().trim();
if (!token.isEmpty()) out.add(token);
}
return out.isEmpty() ? List.of() : List.copyOf(out);
}
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
public boolean hasScope(String scope) {
return hasScope("scope,scp", scope);
}
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
public boolean hasScope(String claimPaths, String scope) {
if (scope == null || scope.isBlank()) return false;
String target = scope.trim();
for (String[] path : splitClaimPaths(claimPaths)) {
Object value = valueAtPath(path);
if (value == null) continue;
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
if (value instanceof List<?> list) {
for (Object item : list) {
if (item == null) continue;
if (target.equals(item.toString().trim())) return true;
}
continue;
}
if (target.equals(value.toString().trim())) return true;
}
return false;
}
// ── Arbitrary claim access ─────────────────────────────────────────────
/**
* Returns the value of any claim, cast to {@code T}.
*
* @throws ClassCastException if the stored value is not assignable to {@code type}
*/
public <T> T claim(String key, Class<T> type) {
return type.cast(claims.get(key));
}
/** Returns the raw claim value, or {@code null} if absent. */
public Object claim(String key) { return claims.get(key); }
/** Escape hatch — returns the full unmodified claims map. */
public Map<String, Object> claims() { return claims; }
// ── Internals ─────────────────────────────────────────────────────────
private String str(String key) {
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
private Object valueAtPath(String[] path) {
Object current = claims;
for (String part : path) {
if (!(current instanceof Map<?, ?> m)) return null;
current = m.get(part);
if (current == null) return null;
}
return current;
}
private static String[][] splitClaimPaths(String claimPaths) {
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
List<String[]> out = new ArrayList<>(4);
int start = 0;
int len = source.length();
for (int i = 0; i <= len; i++) {
if (i == len || source.charAt(i) == ',') {
String raw = source.substring(start, i).trim();
if (!raw.isEmpty()) out.add(splitPath(raw));
start = i + 1;
}
}
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
}
private static String[] splitPath(String path) {
List<String> out = new ArrayList<>(4);
int start = 0;
int len = path.length();
for (int i = 0; i <= len; i++) {
if (i == len || path.charAt(i) == '.') {
String raw = path.substring(start, i).trim();
if (!raw.isEmpty()) out.add(raw);
start = i + 1;
}
}
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
}
private static void appendDelimitedTokens(List<String> target, String source) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
if (i > start) target.add(source.substring(start, i));
}
}
private static boolean containsDelimitedToken(String source, String token) {
int len = source.length();
int i = 0;
while (i < len) {
while (i < len && isDelimiter(source.charAt(i))) i++;
int start = i;
while (i < len && !isDelimiter(source.charAt(i))) i++;
int end = i;
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
return true;
}
}
return false;
}
private static boolean isDelimiter(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
}
}
@@ -1,64 +0,0 @@
package dev.relism.flash.ext.auth;
import java.util.Map;
/**
* The current request's claims, published by {@link AuthMiddleware} before the handler runs and
* cleared in a {@code finally} afterwards.
*
* <p>Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
* isolated per request.
*
* <p>Writing is deliberately not public. A {@link CredentialSource} returns claims and the
* middleware publishes them, so no code outside this module can put claims on a request that did
* not carry them.
*
* <pre>{@code
* // Inside any handler behind @Authenticated or @RolesAllowed:
* Claims caller = ClaimsHolder.current();
* String email = caller.email();
* List<String> roles = caller.roles("realm_access.roles");
*
* // Raw escape hatch:
* Map<String, Object> all = ClaimsHolder.map();
* }</pre>
*/
public final class ClaimsHolder {
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
private ClaimsHolder() {}
/** Called by {@link AuthMiddleware} once a source has authenticated the request. */
static void set(Map<String, Object> claims) {
HOLDER.set(claims);
}
/** Called by {@link AuthMiddleware} in the {@code finally} block. */
static void clear() {
HOLDER.remove();
}
/**
* A typed view of the current request's claims, or {@code null} when the route carries no
* authentication middleware or the caller is anonymous under
* {@link Authenticated}{@code (optional = true)}.
*/
public static Claims current() {
Map<String, Object> claims = HOLDER.get();
return claims != null ? new Claims(claims) : null;
}
/** The raw claims map for the current request, or {@code null}. @see #current() */
public static Map<String, Object> map() {
return HOLDER.get();
}
/** A single claim as a String, or {@code null} when absent or the caller is anonymous. */
public static String claim(String key) {
Map<String, Object> claims = HOLDER.get();
if (claims == null) return null;
Object v = claims.get(key);
return v != null ? v.toString() : null;
}
}
@@ -1,48 +0,0 @@
package dev.relism.flash.ext.auth;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.util.Map;
/**
* Turns whatever a request carries — a bearer token, a session cookie, an API key — into the
* claims authorization runs on. One is installed per authentication mechanism;
* {@code flash-ext-auth-oidc} contributes the OpenID Connect one.
*
* <p>Implementations never touch {@link ClaimsHolder}: they produce claims and {@link
* AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can
* inject claims into a request, which is the point.
*/
public interface CredentialSource {
/**
* Resolves the caller's claims, rejecting the request when it cannot.
*
* <p>Three outcomes, and the difference between the last two matters:
* <ul>
* <li>claims — the caller presented a valid credential;</li>
* <li>{@code null} — no credential was presented and this source has already answered the
* request itself (typically a redirect into a sign-in flow). The middleware stops and
* writes nothing more;</li>
* <li>{@link HttpException} — a credential <em>was</em> presented and is invalid. The source
* sets any challenge header it owes the caller before throwing.</li>
* </ul>
*/
Map<String, Object> authenticate(Request req, Response res);
/**
* Resolves claims without ever rejecting: {@code null} when no valid credential is present.
* Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal
* outcome rather than a failure.
*/
Map<String, Object> peek(Request req);
/**
* The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or
* {@code null} when this source has no such concept. Only consulted after authentication has
* already succeeded.
*/
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link SessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply another implementation to whichever {@link CredentialSource} owns the session.
*/
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
@Override public void save(Session s) { store.put(s.id(), s); }
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,31 +0,0 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers holding at least one of the named roles. Authentication is
* implied — there is no need to combine it with {@link Authenticated}.
*
* <p>Roles are read from the claim path the installed credential source is configured with
* (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or
* {@code groups}). Nested paths use dot notation.
*
* <pre>{@code
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
* @RolesAllowed("admin")
* public class DeleteBlog extends JacksonHandler { ... }
*
* // Multiple accepted roles (OR semantics — any one is sufficient):
* @RolesAllowed({"admin", "editor"})
* public class UpdateBlog extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RolesAllowed {
/** One or more role names. Access is granted if the caller has any of them. */
String[] value();
}
@@ -1,45 +0,0 @@
package dev.relism.flash.ext.auth;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Restricts a handler to callers whose credential carries the required scopes.
* Authentication is implicitly required.
*
* <p>Scopes are resolved from the configured claim paths in
* {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
* both standard formats:
* <ul>
* <li>{@code scope}: space-separated string</li>
* <li>{@code scp}: string list (or string)</li>
* </ul>
*
* <pre>{@code
* @Route(method = HttpMethod.GET, path = "/api/orders")
* @ScopesAllowed("orders:read")
* public class ListOrders extends JacksonHandler { ... }
*
* @Route(method = HttpMethod.POST, path = "/api/orders")
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
* public class CreateOrder extends JacksonHandler { ... }
* }</pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ScopesAllowed {
/** Required scopes. */
String[] value();
/** Matching mode for {@link #value()}. */
Match match() default Match.ALL;
enum Match {
/** Any one required scope is sufficient. */
ANY,
/** All required scopes must be present. */
ALL
}
}
@@ -1,53 +0,0 @@
package dev.relism.flash.ext.auth;
import java.time.Instant;
import java.util.Map;
/**
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
* cookie on every request.
*
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
* {@link #id()}.
*
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
* tokens there so that renewal is its business rather than core's.
*/
public final class Session {
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
private static final long EAGER_RENEWAL_SECONDS = 30;
private final String id;
private final Map<String, Object> claims;
private final Instant expiresAt;
private final Map<String, Object> attributes;
public Session(String id, Map<String, Object> claims, Instant expiresAt,
Map<String, Object> attributes) {
this.id = id;
this.claims = Map.copyOf(claims);
this.expiresAt = expiresAt;
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
}
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
public boolean isExpired() {
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
}
/** One attribute, or {@code null} when the source never stored it. */
public Object attribute(String key) { return attributes.get(key); }
/** One attribute as a String, or {@code null}. */
public String attributeAsString(String key) {
Object v = attributes.get(key);
return v != null ? v.toString() : null;
}
public String id() { return id; }
public Map<String, Object> claims() { return claims; }
public Instant expiresAt() { return expiresAt; }
public Map<String, Object> attributes() { return attributes; }
}
@@ -1,13 +0,0 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
/**
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
*/
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
@@ -1,94 +0,0 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AuthPolicyTest {
static class PlainHandler {}
@Authenticated
static class AuthenticatedHandler {}
@Authenticated(optional = true)
static class OptionalHandler {}
@RolesAllowed({"admin", " editor ", "admin"})
static class RolesHandler {}
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
static class ScopesHandler {}
@Authenticated
@RolesAllowed("admin")
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
static class CombinedHandler {}
@Authenticated(optional = true)
@ScopesAllowed("orders:read")
static class InvalidOptionalHandler {}
@Test
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class));
}
@Test
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertEquals(0, policy.requiredRoles().length);
assertEquals(0, policy.requiredScopes().length);
}
@Test
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class);
assertNotNull(policy);
assertTrue(policy.optionalAuth());
}
@Test
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class);
assertNotNull(policy);
assertFalse(policy.optionalAuth());
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
}
@Test
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class);
assertNotNull(policy);
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
}
@Test
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
assertThrows(IllegalStateException.class,
() -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
}
@Test
void openApiScopesFor_returnsScopesWhenPresent() {
assertEquals(List.of("orders:write", "payments:write"),
AuthPolicy.openApiScopesFor(ScopesHandler.class));
}
@Test
void openApiScopesFor_rolesOnly_returnsEmptyList() {
assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class));
}
@Test
void openApiScopesFor_noSecurity_returnsNull() {
assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class));
}
}
@@ -1,209 +0,0 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Characterisation tests for claim matching — the part of authorization that has nothing to do
* with OIDC: given a claims map, does the caller hold a role or a scope.
*
* <p>Written to pin the <em>current</em> behaviour, including the edges that are easy to change by
* accident: which characters separate scopes in a string claim, whether a list entry is trimmed
* before comparison, what an empty requirement means under each match mode, and how a claim path
* that walks into a non-map resolves. Every assertion here reflects what the code does today, not
* what it arguably should do.
*/
class ClaimMatchingTest {
/** No credential source: every assertion here is about claims that are already resolved. */
private static AuthMiddleware middleware(String rolesPath, String scopePaths) {
return new AuthMiddleware(AuthConfig.builder()
.rolesClaimPath(rolesPath)
.scopeClaimPaths(scopePaths)
.build(), null);
}
private static AuthMiddleware middleware() {
return middleware("realm_access.roles", "scope,scp");
}
// ── Claim path traversal ─────────────────────────────────────────────────
@Test
void aPathWalksNestedMaps() {
Map<String, Object> claims = Map.of("a", Map.of("b", Map.of("c", List.of("x"))));
assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"}));
}
@Test
void aPathThatWalksIntoANonMapResolvesToNothing() {
// "a" is a string, so "a.b" has nowhere to go — not an error, just no match.
Map<String, Object> claims = Map.of("a", "not-a-map");
assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"}));
}
@Test
void aMissingPathResolvesToNothing() {
assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"}));
}
@Test
void emptySegmentsInAPathAreSkipped() {
// "realm_access..roles" collapses to the same two segments.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void segmentsAreTrimmed() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aBlankRolesPathIsRejectedAtConstruction() {
assertThrows(IllegalStateException.class, () -> middleware(" ", "scope"));
}
@Test
void aNullClaimValueResolvesToNothing() {
Map<String, Object> nested = new HashMap<>();
nested.put("roles", null);
Map<String, Object> claims = Map.of("realm_access", nested);
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Roles: ANY semantics ─────────────────────────────────────────────────
@Test
void anyOneOfTheRequiredRolesIsEnough() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"}));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"}));
}
@Test
void requiringNoRoleAtAllMatchesNothing() {
// The loop never runs, so the answer is false even when the claim is present.
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
assertFalse(middleware().rolesAllowed(claims, new String[0]));
}
// ── What counts as "contains" ────────────────────────────────────────────
@Test
void aListClaimMatchesEntrywiseAndTrimsEachEntry() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void aListEntryIsNeverSplitOnDelimiters() {
// Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b".
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("a b")));
assertFalse(middleware().rolesAllowed(claims, new String[]{"a"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"}));
}
@Test
void nullEntriesInAListAreSkipped() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", Arrays.asList(null, "admin")));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void anArrayClaimBehavesLikeAList() {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", (Object) new String[]{"admin", "user"}));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}));
}
@Test
void aScalarClaimIsComparedWhole() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", 42));
assertTrue(middleware().rolesAllowed(claims, new String[]{"42"}));
}
@Test
void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() {
for (String separator : List.of(" ", "\t", "\n", "\r", ",")) {
Map<String, Object> claims = Map.of("realm_access",
Map.of("roles", "admin" + separator + "user"));
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}),
"separator " + separator.strip().isEmpty() + " should split the claim");
}
}
@Test
void aStringClaimDoesNotMatchAPrefixOrASubstring() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", "administrator"));
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
@Test
void repeatedDelimitersProduceNoEmptyTokens() {
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, "));
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
}
// ── Scopes: ALL vs ANY, across several claim paths ───────────────────────
@Test
void allRequiresEveryScope() {
Map<String, Object> claims = Map.of("scope", "openid orders:read");
assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
}
@Test
void anyRequiresOne() {
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY));
assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY));
}
@Test
void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() {
// The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation,
// which rejects an empty value list before it can ever reach here.
Map<String, Object> claims = Map.of("scope", "openid");
assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL));
assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY));
}
@Test
void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() {
AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes");
Map<String, Object> claims = Map.of(
"scp", List.of("payments:write"),
"permissions", Map.of("scopes", "orders:approve"));
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL));
// ALL is satisfied even when the two scopes come from different claims.
assertTrue(mw.scopesAllowed(claims,
new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL));
}
@Test
void blankScopePathsFallBackToScopeAndScp() {
AuthMiddleware mw = middleware("roles", " ");
assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL));
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
@Test
void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() {
AuthMiddleware mw = middleware("roles", " , , ");
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
}
}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ClaimsScopesTest {
@Test
void scopes_readsStandardScopeString() {
Claims user = new Claims(Map.of("scope", "openid profile orders:read"));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
assertTrue(user.hasScope("orders:read"));
assertFalse(user.hasScope("orders:write"));
}
@Test
void scopes_fallsBackToScpArray() {
Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write")));
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
assertTrue(user.hasScope("payments:write"));
}
@Test
void scopes_supportsCustomClaimPaths() {
Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
assertTrue(user.hasScope("permissions.scopes", "a"));
assertFalse(user.hasScope("permissions.scopes", "x"));
}
@Test
void scopes_combinesMultipleClaimPathsInOrder() {
Claims user = new Claims(Map.of(
"scope", "openid",
"scp", List.of("profile", "orders:read")
));
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
}
}
@@ -1,58 +0,0 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The two things a session has to get right: it reports itself expired early enough that it
* cannot lapse midway through a request, and it hands back what a credential source stored on it
* without interpreting any of it.
*/
class SessionTest {
private static Session at(Instant expiry, Map<String, Object> attributes) {
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
}
@Test
void aSessionIsExpiredWellBeforeItsDeadline() {
// The eager window is what stops a session from lapsing between the check and the handler.
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
}
@Test
void attributesAreReturnedUninterpreted() {
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
assertEquals("abc", session.attributeAsString("oidc.id_token"));
assertEquals("7", session.attributeAsString("n"));
assertEquals(7, session.attribute("n"));
assertNull(session.attributeAsString("absent"));
}
@Test
void aSessionWithoutAttributesIsUsableRatherThanNull() {
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
}
@Test
void claimsAndAttributesAreCopiedAndImmutable() {
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
Session session = at(Instant.now().plusSeconds(60), mutable);
mutable.put("k", "changed");
assertEquals("v", session.attributeAsString("k"));
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
}
}
@@ -1,474 +0,0 @@
# flash-ext-auth-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
hot-path model (middleware compiled at mount time, no heavy runtime work).
This extension is the OpenID Connect **credential source** for
[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of
identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including
[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`.
## What it provides
| Component | Description |
|---|---|
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims`
belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind
any credential source. Installing this extension brings them in and wires them up — you do not
install auth-core yourself.
## Dependencies
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-auth-oidc</artifactId>
<version>2.1.0-SNAPSHOT</version>
</dependency>
```
Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`.
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
## Installation
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension(...)) // optional — enables Swagger security
.install(new OidcExtension(
OidcConfig.builder(
"https://idp.example.com",
"my-client", "my-secret", "/auth/callback")
.build()
))
.start();
```
Install order is irrelevant. The two-phase extension model guarantees all services
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
any extension's routes phase runs.
### Keycloak shortcut
```java
OidcConfig.keycloak(
"https://keycloak.example.com", // server URL (no realm)
"myrealm", // realm
"my-client", "my-secret", // client credentials
"/auth/callback") // redirect URI (server-relative)
.https() // behind TLS
.build()
```
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
`{serverUrl}/realms/{realm}`.
### Authelia / generic IdP
```java
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
.rolesClaimPath("groups")
.build()
```
## OidcConfig reference
### Required fields
| Field | Description |
|---|---|
| `issuer` | Provider base URL — also used for OIDC discovery |
| `clientId` | OAuth2 client ID |
| `clientSecret` | OAuth2 client secret |
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
### Builder options
| Method | Default | Description |
|---|---|---|
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
| `.https()` | — | Shorthand for `.selfScheme("https")` |
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) |
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
### Environment variables (`OidcConfig.fromEnv()`)
```
OIDC_ISSUER required
OIDC_CLIENT_ID required
OIDC_CLIENT_SECRET required
OIDC_REDIRECT_URI required e.g. /auth/callback
OIDC_SCOPES default: openid profile email
OIDC_ROUTE_PREFIX default: /auth
OIDC_SELF_SCHEME default: http
OIDC_ROLES_CLAIM default: realm_access.roles
OIDC_SCOPE_CLAIMS default: scope,scp
OIDC_ALGORITHM default: RS256
OIDC_POST_LOGOUT_REDIRECT default: /
OIDC_CLIENT_AUTH_METHOD default: POST
```
## Protecting routes
### Class-based handlers (annotations)
```java
@Route(method = HttpMethod.GET, path = "/me")
@Authenticated
public class MePage extends JacksonHandler {
@Override
public Object handle(Request req, Response res) {
Claims u = ClaimsHolder.current();
return json(res, Map.of("sub", u.sub(), "email", u.email()));
}
}
@Route(method = HttpMethod.GET, path = "/admin")
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
// @RolesAllowed({"admin", "superuser"})
public class AdminPage extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/orders")
@ScopesAllowed("orders:write") // default = ALL semantics
public class CreateOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.POST, path = "/payments")
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
public class PayOrder extends JacksonHandler { ... }
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@RolesAllowed("admin")
@ScopesAllowed("users:delete") // combined with AND semantics
public class DeleteUser extends JacksonHandler { ... }
```
The middleware is injected automatically by the annotation processor — no manual wiring needed.
Annotation composition rules:
- `@Authenticated` requires auth only
- `@RolesAllowed` implies authentication + role OR-check
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
### Lambda routes (manual middleware)
For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware`
from the context inside another extension's `routes()` phase, or after `start()`:
```java
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
// Authentication only
app.get("/api/me", (req, res) -> {
Claims u = ClaimsHolder.current(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
}, auth.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
Claims u = ClaimsHolder.current();
// ...
}, auth.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer"));
// Require all listed scopes
app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write"));
// Require at least one listed scope
app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin"));
```
`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check
runs before your handler.
## Accessing the authenticated user
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has
authenticated the request, and cleared in the `finally` block afterward. Nothing outside that
module can write to it. It is safe with virtual threads (each request gets its
own virtual thread, so `ThreadLocal` values are naturally isolated).
### Claims (preferred)
```java
Claims u = ClaimsHolder.current(); // never null inside a protected handler
String sub = u.sub(); // unique user ID
String email = u.email();
String username = u.username(); // preferred_username
String name = u.name(); // full display name
// Roles — pass the dot-path matching your provider's claim structure
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
List<String> groups = u.roles("groups"); // Authelia
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
List<String> scopes = u.scopes();
boolean canWrite = u.hasScope("orders:write");
// Custom claim path resolution (for provider-specific payloads)
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
// Arbitrary claim
String locale = (String) u.claim("locale");
Long exp = u.claim("exp", Long.class);
// Full raw map (escape hatch)
Map<String, Object> all = u.claims();
```
### Raw access (escape hatch)
```java
Map<String, Object> claims = ClaimsHolder.map();
String email = ClaimsHolder.claim("email");
```
## Performance
The middleware adds negligible overhead on the hot path for authenticated requests:
| Step | Cost |
|---|---|
| `Authorization` header check | `O(1)` map lookup |
| Cookie parse | `O(cookie_length)` single pass scan |
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
| Token expiry check | `O(1)` `Instant` comparison |
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
## Authentication flow details
On each request the middleware resolves credentials in this order:
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
refreshed if the access token is expired (silent refresh via refresh token).
3. **No valid credentials**:
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
- API clients → `401 Unauthorized`
### API error semantics (RFC 6750)
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
- missing credentials: `Bearer realm="<schemeName>"`
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
### Token validation (OIDC Core §3.1.3.7)
| Check | Access token | ID token |
|---|---|---|
| Signature (JWKS) | yes | yes |
| `iss` | yes | yes |
| `aud` = clientId | no (varies by provider) | yes |
| `exp`, `iat`, `sub` | yes | yes |
| `nonce` | — | yes |
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
### Claim merge strategy
At callback time the extension merges access token + ID token claims:
- Access token claims first (contains provider-specific data like `realm_access.roles`)
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
identity claims live in the IT per OIDC Core.
## Standards & compliance notes
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
- RFC 7636 (PKCE): S256 challenge/verifier flow
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
Provider interoperability details:
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
- scope claim fallback chain is configurable via `scopeClaimPaths`
## Testing scopes with Keycloak
Quick path to test `@ScopesAllowed` end-to-end:
1. **Create a client scope**
- Realm -> Client scopes -> Create
- Name: `orders:write` (or any scope name you want to enforce)
2. **Attach it to your client**
- Clients -> `<your-client>` -> Client scopes
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
3. **Ensure scope mapper reaches the token**
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
- Verify the access token contains either `scope` string or `scp` list
4. **Request the scope in Flash config**
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
5. **Protect a handler**
- `@ScopesAllowed("orders:write")` on class-based handlers
- or `auth.requireScopes("orders:write")` for lambda routes
6. **Verify behavior**
- token with scope -> 200
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
Useful token inspection flow while testing:
- Obtain a token from Keycloak
- Decode payload (`jwt.io` or local tool)
- check `scope` / `scp` claims
- call your protected endpoint and inspect status + `WWW-Authenticate`
## Session store
Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its
access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here
and core carries no OAuth2 vocabulary. See
[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md).
The default `InMemorySessionStore` is sufficient for single-instance deployments.
For clustered deployments, implement `SessionStore`:
```java
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
```
```java
OidcConfig.builder(...)
.sessionStore(new RedisSessionStore(redisClient))
.build()
```
`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
## Logout
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
```html
<form method="POST" action="/auth/logout">
<button type="submit">Logout</button>
</form>
```
The `POST {prefix}/logout` handler:
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
2. Deletes the local session and clears the cookie (`Max-Age=0`).
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
the user out of the IdP as well.
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
## Bearer token (API clients)
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
validates the JWT signature against JWKS and extracts the claims — no session involved:
```
Authorization: Bearer <access_token>
```
The token must be a JWT (opaque tokens are not supported). Claims are available via
`ClaimsHolder.current()` as usual.
## Multi-tenant
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
(its own PKCE state store, session store, validator, and middleware):
```java
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
.routePrefix("/a/auth").schemeName("tenantA").build();
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
.routePrefix("/b/auth").schemeName("tenantB").build();
app.install(new OidcExtension(tenantA))
.install(new OidcExtension(tenantB));
```
To reference a specific tenant's middleware on lambda routes, keep the extension instances
and retrieve `AuthMiddleware` from context after `start()`:
```java
OidcExtension extA = new OidcExtension(tenantA);
OidcExtension extB = new OidcExtension(tenantB);
FlashApp app = FlashApp.create(8080)
.install(extA)
.install(extB)
.start()
.join(); // wait for bind
AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB
```
> **Note:** because both extensions register `AuthMiddleware.class` in the same context,
> only the last one wins under that key. For multi-tenant setups, use distinct context
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
> middleware captured from the extension instance before `install()`.
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
registered processor's middleware. For true multi-tenant class-based routing, install
tenant-specific annotation processors with different annotations.
## OpenAPI integration
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
the extension automatically:
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
, `@RolesAllowed`, or `@ScopesAllowed`
No extra code needed. To customize the scheme name:
```java
OidcConfig.builder(...).schemeName("keycloak").build()
```
If `flash-ext-openapi` is absent the integration is silently skipped.
@@ -1,98 +0,0 @@
# flash-ext-auth-oidc
OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens,
server-side sessions with silent refresh, and single logout.
It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md),
which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`,
`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not
install `flash-ext-auth-core` yourself.
## Quick start
```java
app.install(new OidcExtension(
OidcConfig.builder(
"https://keycloak.example.com/realms/myrealm",
"my-app", "secret", "/auth/callback")
.rolesClaimPath("realm_access.roles")
.https()
.build()));
```
That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable,
so a misconfigured provider is a startup crash rather than a 500 on the first login.
`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and
`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you.
## What it registers
| | |
|---|---|
| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects |
| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session |
| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint |
`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is
one `<img>` tag away from being triggered by any page the user visits.
In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and
`JwtValidator`.
## How a request is resolved
1. `Authorization: Bearer …` — validated against the issuer's JWKS.
2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a
refresh token is present, refreshed transparently and the session replaced.
3. Neither, and the client sent `Accept: application/json``401` with a
`WWW-Authenticate: Bearer` challenge.
4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`.
Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API
client must not be redirected into an HTML sign-in page, and a browser must not be left staring at
a bare 401.
## Configuration
| Setting | Default | Notes |
|---|---|---|
| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required |
| `scopes` | `openid profile email` | |
| `routePrefix` | `/auth` | |
| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` |
| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia |
| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order |
| `algorithm` | `RS256` | |
| `postLogoutRedirectUri` | `/` | |
| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances |
| `clientAuthMethod` | `POST` | token endpoint client authentication |
| `insecureTls()` | off | dev only, skips certificate validation |
| `schemeName` | derived from the issuer | OpenAPI security scheme name |
A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`,
or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS
without a second configuration.
## Sessions
A session holds the claims plus the access, id and refresh tokens, the last three in
`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens
here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md).
## Multiple providers
Two issuers on one server, each with its own route prefix:
```java
app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth")
.install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth")
```
Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from
a settings page — is not supported.
## Interop
See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`,
`flash-ext-openapi` and `flash-ext-mcp`.
@@ -1,88 +0,0 @@
# Interop
## flash-ext-auth-core
A hard dependency, and the reason this extension is as small as it is. The division:
| Here | `flash-ext-auth-core` |
|---|---|
| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` |
| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` |
| Bearer and cookie resolution, refresh | Role and scope matching |
| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` |
`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and
that publishes the middleware and registers the annotation processor. Everything a handler
annotation does is core's code running against claims this extension produced.
Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps
this extension for another credential source keeps every annotation it had.
## flash-ext-openapi
Optional, and resolved lazily so this extension runs standalone when openapi is not on the
classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme
with the `authorizationCode` flow, filled in from the discovery document:
```json
"securitySchemes": {
"myrealm": {
"type": "oauth2",
"flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {} } }
}
}
```
Per-operation security comes from the same annotations the middleware reads, so the spec and the
enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`.
The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm
name, usually) unless set explicitly.
## flash-ext-mcp
`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)`
— and not merely whether something authenticates:
| Policy | this extension installed | absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** |
| `AUTO` | protected | unprotected, warning logged |
| `NONE` | never protected | unprotected |
That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting
this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707
audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless
without an issuer. An app that authenticates some other way must not satisfy it by accident.
When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the
source with no extra `McpConfig` calls:
- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()`
the same validation every other route uses, plus the `resource_metadata` challenge parameter;
- an audience guard runs after it and rejects any token whose `aud` does not include this
endpoint's resource identifier;
- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host`
header and `selfScheme`.
An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own
guard to `McpConfig.middleware(...)`.
## Migrating from flash-ext-oidc
The module was renamed and its generic half moved. Mechanically:
| Was | Now |
|---|---|
| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` |
| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` |
| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) |
| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` |
| `OidcUser` | `Claims` |
| `ClaimsHolder.user()` | `ClaimsHolder.current()` |
| `ClaimsHolder.get()` | `ClaimsHolder.map()` |
| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` |
| `session.isAccessTokenExpired()` | `session.isExpired()` |
| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` |
`OidcConfig`, `OidcExtension` and every setting on them are unchanged.
@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-auth-oidc</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-auth-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<dependency>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -1,18 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
*
* <ul>
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
* form fields (default; most providers).</li>
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
* body contains only grant-specific parameters.</li>
* </ul>
*/
public enum ClientAuthMethod {
/** {@code client_secret_post} — credentials in the request body. */
POST,
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
BASIC
}
@@ -1,50 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* Fetches and parses the OIDC provider discovery document at
* {@code {issuer}/.well-known/openid-configuration}.
*/
final class DiscoveryClient {
private DiscoveryClient() {}
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
String url = issuer.endsWith("/")
? issuer + ".well-known/openid-configuration"
: issuer + "/.well-known/openid-configuration";
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IllegalStateException(
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
@SuppressWarnings("unchecked")
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcProviderMetadata(
require(doc, "authorization_endpoint"),
require(doc, "token_endpoint"),
(String) doc.get("userinfo_endpoint"), // optional
require(doc, "jwks_uri"),
(String) doc.get("end_session_endpoint") // optional
);
}
private static String require(Map<String, Object> doc, String key) {
Object v = doc.get(key);
if (v == null) throw new IllegalStateException(
"Discovery doc missing required field: " + key);
return v.toString();
}
}
@@ -1,38 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* Low-level JWT payload extraction — no signature or expiry validation.
*
* <p>Use only for tokens received directly from the provider over a trusted TLS
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
*/
final class JwtUtils {
private JwtUtils() {}
/**
* Base64URL-decodes the JWT payload and returns the claims as a map.
* Signature, expiry, and issuer are NOT checked.
*/
@SuppressWarnings("unchecked")
static Map<String, Object> parseClaims(String jwt) {
String[] parts = jwt.split("\\.");
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
// Pad to a multiple of 4 for the standard decoder
String padded = parts[1];
switch (padded.length() % 4) {
case 2 -> padded += "==";
case 3 -> padded += "=";
}
byte[] payload = Base64.getUrlDecoder().decode(padded);
return (Map<String, Object>) JSONValue.parse(
new String(payload, StandardCharsets.UTF_8));
}
}
@@ -1,184 +0,0 @@
package dev.relism.flash.ext.oidc;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.Resource;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.exceptions.HttpException;
import java.io.IOException;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.Set;
/**
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
*
* <p>Two validation modes:
* <ul>
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
* {@code sub}, and {@code nonce} (if provided).
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
* </ul>
*
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
* fetch serves both token types.
*/
public class JwtValidator {
private final JWKSource<SecurityContext> jwkSource;
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
private final String algorithm;
/**
* @param jwksUri JWKS endpoint URI
* @param issuer Expected {@code iss} claim
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
* with the correct TLS policy (trust-all or default trust store).
*/
public JwtValidator(String jwksUri, String issuer, String clientId,
String algorithm, HttpClient http) {
try {
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
// (insecureTls / custom trust store) is applied consistently everywhere.
this.jwkSource = JWKSourceBuilder
.create(new URL(jwksUri), httpRetriever(http))
.cache(true)
.rateLimited(true)
.retrying(true)
.build();
} catch (Exception e) {
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
}
this.algorithm = algorithm;
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
}
// -- Public API -----------------------------------------------------------
/**
* Validates a JWT access token (bearer on incoming request).
* Returns claims on success; throws {@link HttpException} 401 on any failure.
*/
public Map<String, Object> validate(String token) {
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
try {
return accessTokenProcessor.process(token, null).getClaims();
} catch (Exception e) {
throw HttpException.unauthorized();
}
}
/**
* Validates an ID token received directly from the token endpoint.
*
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
*
* @param idToken Raw ID token string
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
* @throws OidcValidationException on any validation failure
*/
public Map<String, Object> validateIdToken(String idToken, String nonce) {
try {
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
if (nonce != null && !nonce.equals(claims.get("nonce")))
throw new OidcValidationException("ID token nonce mismatch", null);
return claims;
} catch (OidcValidationException e) {
throw e;
} catch (Exception e) {
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
}
}
/**
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
* Used to detect opaque access tokens before attempting JWKS validation.
*/
public static boolean isJwt(String token) {
if (token == null || token.isBlank()) return false;
int dots = 0;
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
return dots == 2;
}
// -- Processors -----------------------------------------------------------
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss required; aud not enforced on ATs (varies by provider)
if (issuer != null && !issuer.isBlank()) {
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder().issuer(issuer).build(),
Set.of("sub", "iat", "exp")));
}
return p;
}
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
p.setJWSKeySelector(keySelector(src, algorithm));
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
if (issuer != null) required.issuer(issuer);
if (clientId != null) required.audience(clientId);
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
required.build(), Set.of("sub", "iat", "exp")));
return p;
}
private static JWSKeySelector<SecurityContext> keySelector(
JWKSource<SecurityContext> src, String algorithm) {
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
}
/**
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
* The client already carries the correct TLS policy (trust-all or default),
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
*/
private static ResourceRetriever httpRetriever(HttpClient http) {
return url -> {
try {
HttpResponse<String> resp = http.send(
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200)
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
String contentType = resp.headers()
.firstValue("Content-Type").orElse("application/json");
return new Resource(resp.body(), contentType);
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
}
};
}
}
@@ -1,264 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.auth.InMemorySessionStore;
import dev.relism.flash.ext.auth.SessionStore;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
*
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
* {@code redirectUri}. Everything else has a sensible default.
*
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
*
* <pre>{@code
* // Keycloak
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles") // Keycloak default
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
* .build();
*
* // Authelia
* OidcConfig.builder(
* "https://auth.example.com",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("groups")
* .scopeClaimPaths("scope,scp")
* .build();
*
* // Two tenants on one server
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
* .routePrefix("/tenantA/auth").build();
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
* .routePrefix("/tenantB/auth").build();
* app.install(new OidcExtension(tenantA))
* .install(new OidcExtension(tenantB));
* }</pre>
*/
public final class OidcConfig {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private final String scopes;
private final String routePrefix;
private final String selfScheme;
private final String rolesClaimPath;
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final SessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
private OidcConfig(Builder b) {
this.issuer = require(b.issuer, "issuer");
this.clientId = require(b.clientId, "clientId");
this.clientSecret = require(b.clientSecret, "clientSecret");
this.redirectUri = require(b.redirectUri, "redirectUri");
this.scopes = b.scopes;
this.routePrefix = b.routePrefix;
this.selfScheme = b.selfScheme;
this.rolesClaimPath = b.rolesClaimPath;
this.scopeClaimPaths = b.scopeClaimPaths;
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemorySessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
}
// -- Getters --------------------------------------------------------------
public String issuer() { return issuer; }
public String clientId() { return clientId; }
public String clientSecret() { return clientSecret; }
public String redirectUri() { return redirectUri; }
public String scopes() { return scopes; }
public String routePrefix() { return routePrefix; }
public String selfScheme() { return selfScheme; }
public String rolesClaimPath() { return rolesClaimPath; }
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public SessionStore sessionStore() { return sessionStore; }
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
public String schemeName() { return schemeName; }
// -- Factory --------------------------------------------------------------
/**
* Reads configuration from environment variables:
* <pre>
* OIDC_ISSUER required
* OIDC_CLIENT_ID required
* OIDC_CLIENT_SECRET required
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
* OIDC_SCOPES default: openid profile email
* OIDC_ROUTE_PREFIX default: /auth
* OIDC_SELF_SCHEME default: http
* OIDC_ROLES_CLAIM default: realm_access.roles
* OIDC_SCOPE_CLAIMS default: scope,scp
* OIDC_ALGORITHM default: RS256
* OIDC_POST_LOGOUT_REDIRECT default: /
* </pre>
*/
public static OidcConfig fromEnv() {
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
.clientAuthMethod(ClientAuthMethod.valueOf(
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
.build();
}
public static Builder builder(String issuer, String clientId,
String clientSecret, String redirectUri) {
return new Builder(issuer, clientId, clientSecret, redirectUri);
}
/**
* Convenience factory for Keycloak: constructs the issuer as
* {@code {serverUrl}/realms/{realm}} automatically.
*
* <pre>{@code
* OidcConfig.keycloak(
* "https://keycloak.example.com", "flashboard",
* "my-app", "secret", "/auth/callback")
* .https()
* .build();
* }</pre>
*/
public static Builder keycloak(String serverUrl, String realm,
String clientId, String clientSecret,
String redirectUri) {
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
String issuer = base + "/realms/" + realm;
return new Builder(issuer, clientId, clientSecret, redirectUri)
.rolesClaimPath("realm_access.roles"); // Keycloak default
}
// -- Helpers --------------------------------------------------------------
private static String require(String v, String name) {
if (v == null || v.isBlank())
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
return v;
}
private static String env(String key) {
String v = System.getenv(key);
if (v == null || v.isBlank())
throw new IllegalArgumentException("Missing required env var: " + key);
return v;
}
private static String envOr(String key, String def) {
String v = System.getenv(key);
return (v != null && !v.isBlank()) ? v : def;
}
// -- Builder --------------------------------------------------------------
public static final class Builder {
private final String issuer;
private final String clientId;
private final String clientSecret;
private final String redirectUri;
private String scopes = "openid profile email";
private String routePrefix = "/auth";
private String selfScheme = "http";
private String rolesClaimPath = "realm_access.roles";
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private SessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
this.issuer = issuer;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
}
/** Override requested scopes (default: {@code openid profile email}). */
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
/** Route prefix for login/callback/logout (default: {@code /auth}). */
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
/** Shorthand for {@code selfScheme("https")}. */
public Builder https() { return selfScheme("https"); }
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
/** JWS algorithm (default: {@code RS256}). */
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemorySessionStore}). */
public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
/**
* Disables TLS certificate verification for all HTTP calls made by this extension.
* <b>Only use in development with self-signed certificates — never in production.</b>
*/
public Builder insecureTls() { this.insecureTls = true; return this; }
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
public Builder schemeName(String name) { this.schemeName = name; return this; }
public OidcConfig build() { return new OidcConfig(this); }
}
/**
* Derives a short, human-readable scheme name from the issuer URI.
* Takes the last non-empty path segment; falls back to the host.
*
* <p>Examples:
* <ul>
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
* </ul>
*/
private static String deriveScheme(String issuer) {
try {
java.net.URI uri = new java.net.URI(issuer);
String path = uri.getPath();
if (path != null && !path.isEmpty()) {
String[] parts = path.split("/");
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty()) return parts[i];
}
}
return uri.getHost();
} catch (Exception e) {
return "oidc";
}
}
}
@@ -1,333 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.auth.CredentialSource;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and
* rejects it the way OAuth2 says to when it cannot. Authorization on those claims is
* {@code flash-ext-auth-core}'s job, not this class's.
*
* <p>Resolution order on each request:
* <ol>
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
* <li>{@code oidc_session} cookie — looked up in {@link dev.relism.flash.ext.auth.SessionStore}; transparently
* refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
* {@code {routePrefix}/login?redirect={path}}.</li>
* <li>API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.</li>
* </ol>
*/
public final class OidcCredentialSource implements CredentialSource {
private static final String BEARER = "Bearer";
/**
* Keys this source stores its OAuth2 tokens under in {@link Session#attributes()}. Core keeps
* the session; the tokens inside it are nobody else's business.
*/
static final String ACCESS_TOKEN = "oidc.access_token";
static final String ID_TOKEN = "oidc.id_token";
static final String REFRESH_TOKEN = "oidc.refresh_token";
/** The one place an OIDC session is built, so its attribute keys stay in one place too. */
static Session newSession(String id, String accessToken, String idToken, String refreshToken,
Instant expiresAt, Map<String, Object> claims) {
Map<String, Object> attributes = new HashMap<>(3);
if (accessToken != null) attributes.put(ACCESS_TOKEN, accessToken);
if (idToken != null) attributes.put(ID_TOKEN, idToken);
if (refreshToken != null) attributes.put(REFRESH_TOKEN, refreshToken);
return new Session(id, claims, expiresAt, attributes);
}
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
private final TokenClient tokenClient;
private final String resourceMetadataPath;
OidcCredentialSource(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient) {
this(validator, config, meta, tokenClient, null);
}
private OidcCredentialSource(JwtValidator validator, OidcConfig config,
OidcProviderMetadata meta, TokenClient tokenClient,
String resourceMetadataPath) {
this.validator = validator;
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
this.resourceMetadataPath = resourceMetadataPath;
}
// -- CredentialSource -----------------------------------------------------
/** OIDC issuer this source validates tokens against — the {@code iss} claim it enforces. */
public String issuer() { return config.issuer(); }
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
public String selfScheme() { return config.selfScheme(); }
/**
* A copy of this source whose 401 challenges also carry {@code resource_metadata}
* (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like
* {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g.
* {@code "/.well-known/oauth-protected-resource/mcp"}.
*
* <p>Used by {@code flash-ext-mcp} to make its Protected Resource Metadata document
* discoverable straight from the {@code WWW-Authenticate} header, per the MCP Authorization
* spec.
*/
public OidcCredentialSource withResourceMetadata(String path) {
return new OidcCredentialSource(validator, config, meta, tokenClient, path);
}
@Override
public Map<String, Object> authenticate(Request req, Response res) {
return resolve(req, res, resourceMetadataPath);
}
@Override
public Map<String, Object> peek(Request req) {
return resolveQuiet(req);
}
@Override
public String insufficientScopeChallenge(String[] requiredScopes) {
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ quoted(spaceDelimited(requiredScopes)) + "\"";
}
// -- Internals ------------------------------------------------------------
/**
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
* when no valid credentials are present. Used by {@link #optional()}.
*/
private Map<String, Object> resolveQuiet(Request req) {
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (Exception ignored) {
return null;
}
}
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
Session session = found.get();
if (!session.isExpired())
return session.claims();
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) { }
}
config.sessionStore().delete(sessionId);
}
}
return null;
}
/**
* Returns claims on success, or {@code null} if a redirect was already written to
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
*/
private Map<String, Object> resolve(Request req, Response res) {
return resolve(req, res, null);
}
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
// 1. Bearer token
String bearerToken = extractBearerToken(req.header("Authorization"));
if (bearerToken != null) {
try {
return validator.validate(bearerToken);
} catch (HttpException e) {
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
throw e;
}
}
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
Session session = found.get();
if (!session.isExpired())
return session.claims();
// Access token expired — try silent refresh
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) {
// Refresh failed — fall through to re-authenticate
}
}
config.sessionStore().delete(sessionId);
}
}
// 3. No valid credentials
String accept = req.header("Accept");
if (accept != null && accept.contains("application/json")) {
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
throw HttpException.unauthorized();
}
// Browser — redirect to login, preserving the original URL in state
String loginUrl = config.routePrefix() + "/login?redirect="
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
res.redirect(loginUrl);
return null;
}
private Session doRefresh(Session old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
return newSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
Instant.now().plusSeconds(tokens.expiresIn()),
mergeRefreshedClaims(tokens, old)
);
}
static String extractBearerToken(String authorizationHeader) {
if (authorizationHeader == null) return null;
int len = authorizationHeader.length();
int start = 0;
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
int schemeEnd = start + BEARER.length();
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
return null;
}
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
return null;
}
int tokenStart = schemeEnd;
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
if (tokenStart >= len) return null;
int tokenEnd = len;
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
}
String bearerChallenge() {
return bearerChallenge(null, null);
}
private String bearerChallenge(Request req, String resourceMetadataPath) {
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
if (resourceMetadataPath == null) return base;
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
}
String invalidTokenChallenge() {
return invalidTokenChallenge(null, null);
}
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
}
private String absoluteSelf(Request req, String path) {
if (!path.startsWith("/")) return path;
return selfOrigin(req, config.selfScheme()) + path;
}
/**
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
* request's own {@code Host} is the upstream address the proxy dialled, so
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
* passing the proxy can do worse than spoof a self URL.
*/
public static String selfOrigin(Request req, String fallbackScheme) {
String forwardedHost = req.header("X-Forwarded-Host");
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
String forwardedProto = req.header("X-Forwarded-Proto");
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
}
private static String spaceDelimited(String[] values) {
if (values == null || values.length == 0) return "";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < values.length; i++) {
if (i > 0) sb.append(' ');
sb.append(values[i]);
}
return sb.toString();
}
private static String quoted(String value) {
StringBuilder out = new StringBuilder(value.length() + 8);
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '"' || c == '\\') out.append('\\');
out.append(c);
}
return out.toString();
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, Session old) {
Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.claims());
if (tokens.accessToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null)
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
// -- Shared cookie utility (also used by OidcExtension) -------------------
static String cookieValue(Request req, String name) {
String header = req.header("Cookie");
if (header == null || header.isBlank()) return null;
int len = header.length();
int start = 0;
while (start < len) {
int semi = header.indexOf(';', start);
int end = semi < 0 ? len : semi;
int eq = header.indexOf('=', start);
if (eq > start && eq < end) {
int ns = start, ne = eq;
while (ns < ne && header.charAt(ns) == ' ') ns++;
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
return header.substring(eq + 1, end).strip();
}
start = end + 1;
}
return null;
}
}
@@ -1,343 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.auth.AuthConfig;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.ext.auth.AuthMiddleware;
import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated;
import dev.relism.flash.ext.auth.RolesAllowed;
import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.*;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
*
* <p>At {@link #provide}, the extension:
* <ol>
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
* <li>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
* in the context.</li>
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
* and {@link ScopesAllowed}.</li>
* </ol>
*
* <p>At {@link #routes}, three routes are registered:
* <ul>
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
* </ul>
*
* <pre>{@code
* // Keycloak
* app.install(new OidcExtension(
* OidcConfig.builder(
* "https://keycloak.example.com/realms/myrealm",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("realm_access.roles")
* .build()));
*
* // Two providers / tenants on one server
* app.install(new OidcExtension(tenantAConfig))
* .install(new OidcExtension(tenantBConfig));
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private final OidcConfig config;
// Initialized in provide(), used in routes() — private to this extension instance.
private OidcProviderMetadata meta;
private OidcStateStore stateStore;
private TokenClient tokenClient;
private JwtValidator validator;
private OidcCredentialSource source;
private AuthMiddleware authMw;
public OidcExtension(OidcConfig config) {
this.config = config;
}
// ── Phase 1: services ─────────────────────────────────────────────────────
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
HttpClient http = buildHttpClient(config);
// Discover provider endpoints (blocking; fail fast at startup).
try {
meta = DiscoveryClient.fetch(config.issuer(), http);
} catch (Exception e) {
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
}
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
source = new OidcCredentialSource(validator, config, meta, tokenClient);
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath(config.rolesClaimPath())
.scopeClaimPaths(config.scopeClaimPaths())
.build(), source);
ctx.provide(OidcCredentialSource.class, source);
ctx.provide(JwtValidator.class, validator);
ctx.onReady(() -> registerRoutes(app, ctx));
}
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
// Builds the provider authorization URL with PKCE + state and redirects.
// Optional query param: ?redirect={relative-url} (default: /)
app.get(prefix + "/login", (req, res) -> {
String verifier = PkceUtils.generateVerifier();
String challenge = PkceUtils.computeChallenge(verifier);
String state = UUID.randomUUID().toString();
String nonce = UUID.randomUUID().toString();
String redirect = req.query("redirect");
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
stateStore.put(state, redirect, verifier, nonce);
String authUrl = meta.authorizationEndpoint()
+ "?response_type=code"
+ "&client_id=" + enc(config.clientId())
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
+ "&scope=" + enc(config.scopes())
+ "&state=" + state
+ "&nonce=" + enc(nonce)
+ "&code_challenge=" + challenge
+ "&code_challenge_method=S256";
res.redirect(authUrl);
return null;
});
// ── GET {prefix}/callback ─────────────────────────────────────────────
// Validates state, exchanges code for tokens, creates session, redirects.
app.get(prefix + "/callback", (req, res) -> {
String error = req.query("error");
if (error != null) {
res.status(400);
return "Authentication error: " + error
+ (req.query("error_description") != null
? "" + req.query("error_description") : "");
}
String code = req.query("code");
String state = req.query("state");
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
if (entry == null) {
res.status(400);
return "Invalid or expired state parameter";
}
OidcTokenResponse tokens = tokenClient.exchangeCode(
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
if (tokens.idToken() != null) {
try {
validator.validateIdToken(tokens.idToken(), entry.nonce());
} catch (OidcValidationException e) {
res.status(400);
return "ID token validation failed: " + e.getMessage();
}
}
Map<String, Object> claims = mergeClaims(tokens);
Session session = OidcCredentialSource.newSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
config.sessionStore().save(session);
res.header("Set-Cookie", sessionCookie(session.id()))
.redirect(entry.originalUrl());
return null;
});
// ── POST {prefix}/logout ──────────────────────────────────────────────
// Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
Session session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
config.sessionStore().delete(sessionId);
}
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
String location;
if (meta.endSessionEndpoint() != null) {
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
.append("?post_logout_redirect_uri=").append(enc(postLogout));
if (idTokenHint != null)
url.append("&id_token_hint=").append(enc(idTokenHint));
location = url.toString();
} else {
location = config.postLogoutRedirectUri();
}
res.header("Set-Cookie", clearCookie).redirect(location);
return null;
});
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
try {
OpenApiIntegration.register(ctx, config, meta);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Merges claims from both the access token and the ID token.
* ID token values win on conflict so that verified identity claims are authoritative.
*/
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
Map<String, Object> merged = new HashMap<>();
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
/**
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
* installs a trust-all {@link SSLContext} that accepts any certificate.
* <b>Only safe for development with self-signed certificates.</b>
*/
private static HttpClient buildHttpClient(OidcConfig config) {
if (!config.insecureTls()) return HttpClient.newHttpClient();
try {
TrustManager[] trustAll = { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
public void checkClientTrusted(X509Certificate[] c, String a) {}
public void checkServerTrusted(X509Certificate[] c, String a) {}
}};
SSLContext sslCtx = SSLContext.getInstance("TLS");
sslCtx.init(null, trustAll, new SecureRandom());
return HttpClient.newBuilder().sslContext(sslCtx).build();
} catch (Exception e) {
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
}
}
private String absoluteRedirectUri(Request req) {
return absoluteSelf(req, config.redirectUri());
}
private String absoluteSelf(Request req, String uri) {
if (!uri.startsWith("/")) return uri;
return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static String sessionCookie(String id) {
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
}
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
Map<String, String> scopesMap = new LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
Map<String, Object> flow = new LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
Map<String, Object> scheme = new LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", Map.of("authorizationCode", flow));
Map<String, Object> securitySchemes = new LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return Map.of("securitySchemes", securitySchemes);
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
OpenApiOperationContribution.Builder out =
OpenApiOperationContribution.builder();
List<String> operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
return out.build();
}
}));
}
private static String roleRequiredMessage(String[] roles) {
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
return "Roles \"" + String.join(", ", roles) + "\" are required";
}
private static String scopeRequiredMessage(String[] scopes) {
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
}
}
}
@@ -1,15 +0,0 @@
package dev.relism.flash.ext.oidc;
/**
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
*
* <p>{@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
* (e.g. some Authelia configurations omit it).
*/
public record OidcProviderMetadata(
String authorizationEndpoint,
String tokenEndpoint,
String userinfoEndpoint,
String jwksUri,
String endSessionEndpoint // nullable
) {}
@@ -1,39 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Short-lived store mapping state nonces → (original URL, PKCE verifier).
*
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
* access to prevent unbounded growth without needing a background thread.
*/
final class OidcStateStore {
static final int TTL_SECONDS = 600; // 10 minutes
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
void put(String state, String originalUrl, String codeVerifier, String nonce) {
cleanup();
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
Instant.now().plusSeconds(TTL_SECONDS)));
}
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
Optional<Entry> consumeAndRemove(String nonce) {
cleanup();
Entry e = store.remove(nonce);
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
return Optional.of(e);
}
private void cleanup() {
Instant now = Instant.now();
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
}
}
@@ -1,10 +0,0 @@
package dev.relism.flash.ext.oidc;
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
record OidcTokenResponse(
String accessToken,
String idToken, // may be null on refresh if provider omits it
String refreshToken, // may be null
int expiresIn,
int refreshExpiresIn
) {}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
/**
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
* Distinct from {@link HttpException}: this signals a protocol-level
* failure, not an HTTP response — callers decide the appropriate status code.
*/
public final class OidcValidationException extends RuntimeException {
public OidcValidationException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -1,36 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
* Package-private — used exclusively by {@link OidcExtension}.
*/
final class PkceUtils {
private static final SecureRandom RANDOM = new SecureRandom();
private PkceUtils() {}
/**
* Generates a cryptographically random code verifier (43 URL-safe characters,
* per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL).
*/
static String generateVerifier() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
*/
static String computeChallenge(String verifier) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
@@ -1,112 +0,0 @@
package dev.relism.flash.ext.oidc;
import net.minidev.json.JSONValue;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
*
* <p>Supports two client authentication methods (RFC 6749 §2.3):
* <ul>
* <li>{@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})</li>
* <li>{@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header
* ({@code client_secret_basic})</li>
* </ul>
*/
final class TokenClient {
private final HttpClient http;
private final String clientId;
private final String clientSecret;
private final ClientAuthMethod authMethod;
TokenClient(HttpClient http, OidcConfig config) {
this.http = http;
this.clientId = config.clientId();
this.clientSecret = config.clientSecret();
this.authMethod = config.clientAuthMethod();
}
/** Authorization Code + PKCE exchange. */
OidcTokenResponse exchangeCode(String tokenEndpoint,
String code, String redirectUri,
String codeVerifier) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "authorization_code");
params.put("code", code);
params.put("redirect_uri", redirectUri);
params.put("code_verifier", codeVerifier);
return post(tokenEndpoint, params);
}
/** Refresh token grant. */
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
Map<String, String> params = new LinkedHashMap<>();
params.put("grant_type", "refresh_token");
params.put("refresh_token", refreshToken);
return post(tokenEndpoint, params);
}
// -- Internals ------------------------------------------------------------
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/x-www-form-urlencoded");
if (authMethod == ClientAuthMethod.BASIC) {
String creds = Base64.getEncoder().encodeToString(
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
req.header("Authorization", "Basic " + creds);
} else {
params.put("client_id", clientId);
params.put("client_secret", clientSecret);
}
HttpResponse<String> resp = http.send(
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
throw new IllegalStateException(
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
@SuppressWarnings("unchecked")
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
return new OidcTokenResponse(
(String) json.get("access_token"),
(String) json.get("id_token"),
(String) json.get("refresh_token"),
numInt(json, "expires_in", 300),
numInt(json, "refresh_expires_in", 1800)
);
}
private static String form(Map<String, String> params) {
StringBuilder sb = new StringBuilder();
params.forEach((k, v) -> {
if (!sb.isEmpty()) sb.append('&');
sb.append(enc(k)).append('=').append(enc(v));
});
return sb.toString();
}
private static String enc(String v) {
return URLEncoder.encode(v, StandardCharsets.UTF_8);
}
private static int numInt(Map<String, Object> m, String key, int def) {
Object v = m.get(key);
return v instanceof Number n ? n.intValue() : def;
}
}
@@ -1,43 +0,0 @@
package dev.relism.flash.ext.oidc;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer
* token off the wire, and the RFC 6750 challenges this source answers with. The matching of
* claims those credentials produce is {@code ClaimMatchingTest}'s job now.
*/
class OidcCredentialSourceTest {
private static OidcCredentialSource source() {
return new OidcCredentialSource(null, OidcConfig
.builder("https://idp.example.com", "client", "secret", "/auth/callback")
.build(), null, null);
}
@Test
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi"));
assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc "));
assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg=="));
assertNull(OidcCredentialSource.extractBearerToken("Bearer"));
}
@Test
void bearerChallenge_containsRealmAndRfcErrors() {
OidcCredentialSource src = source();
String basic = src.bearerChallenge();
String invalid = src.invalidTokenChallenge();
String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
assertTrue(basic.startsWith("Bearer realm=\""));
assertTrue(invalid.contains("error=\"invalid_token\""));
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
}
}
@@ -1,128 +0,0 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.openapi.OpenApiContributor;
import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated;
import dev.relism.flash.ext.auth.RolesAllowed;
import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.extension.FlashContext;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OidcOpenApiInteropTest {
@Authenticated
static class AuthOnly {}
@Authenticated(optional = true)
static class AuthOptional {}
@RolesAllowed("admin")
static class OneRole {}
@RolesAllowed({"admin", "operator"})
static class MultiRole {}
@ScopesAllowed("orders:write")
static class OneScope {}
@ScopesAllowed({"orders:write", "payments:write"})
static class MultiScope {}
@RolesAllowed("admin")
@ScopesAllowed("orders:write")
static class RoleAndScope {}
@Test
void autoResponses_authOnly() throws Exception {
Map<Integer, String> responses = responses(AuthOnly.class);
assertEquals("Authentication required", responses.get(401));
assertFalse(responses.containsKey(403));
}
@Test
void autoResponses_optionalAuth_addsNothing() throws Exception {
Map<Integer, String> responses = responses(AuthOptional.class);
assertTrue(responses.isEmpty());
}
@Test
void autoResponses_oneRole_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneRole.class);
assertEquals("Authentication required", responses.get(401));
assertEquals("\"admin\" role required", responses.get(403));
}
@Test
void autoResponses_multiRoles_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiRole.class);
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
}
@Test
void autoResponses_oneScope_formatsSingular() throws Exception {
Map<Integer, String> responses = responses(OneScope.class);
assertEquals("\"orders:write\" scope required", responses.get(403));
}
@Test
void autoResponses_multiScopes_formatsPlural() throws Exception {
Map<Integer, String> responses = responses(MultiScope.class);
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
}
@Test
void autoResponses_roleAndScope_combinesMessages() throws Exception {
Map<Integer, String> responses = responses(RoleAndScope.class);
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
}
@Test
void securityContribution_presentForAuthenticatedHandler() throws Exception {
OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
List<Map<String, List<String>>> security = operation.security();
assertEquals(1, security.size());
assertTrue(security.getFirst().containsKey("issuer"));
}
private static OpenApiContributor contributor() throws Exception {
Class<?> clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration");
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
Object instance = ctor.newInstance();
Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
m.setAccessible(true);
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.complete();
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
m.invoke(instance, ctx, config, meta);
return registry.contributors().getFirst();
}
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
Map<Integer, OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, OpenApiResponseContribution> e : byCode.entrySet()) {
out.put(e.getKey(), e.getValue().description());
}
return out;
}
}
@@ -1,85 +0,0 @@
# flash-ext-cache-caffeine
In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements
[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md).
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-caffeine</artifactId>
<version>${flash.version}</version>
</dependency>
```
## Quick start
```java
FlashApp.create(8080)
.install(new CaffeineCacheExtension())
.scan("dev.example.api");
```
```java
@GET("/api/users/{id}")
public final class GetUser extends RequestHandler {
private Cache<String, User> users;
private UserRepository repo;
@Override protected void onInit() {
repo = require(UserRepository.class);
users = require(CacheManager.class).build("users", spec -> spec
.maxSize(10_000)
.ttl(Duration.ofMinutes(10)));
}
@Override public Object handle(Request req, Response res) {
return users.get(req.param("id"), repo::findById);
}
}
```
The extension takes no configuration. Each cache declares its own size and TTL where it is built.
## Why Caffeine and not a `LinkedHashMap`
An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely
low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no
hit rate to get wrong.
This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission,
striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the
failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on
every lookup, or an eviction policy that throws away exactly the entries you were about to want.
## Lifecycle
Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is
invisible in production with one app per process and matters immediately under test, where many
apps start and stop in one JVM.
## Statistics
```java
CacheStats stats = users.stats();
stats.hitRate(); // 0.0 until something is looked up
```
Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest
about being unmeasured rather than reporting zeroes that look like a cold cache.
`manager.names()` lists every cache built so far, for an ops endpoint.
## What this is not
**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`,
`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request
rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different
problems.
**A shared cache.** Every replica has its own. Two instances will hold different values for the
same key, and an invalidation on one does not reach the other. When that becomes a problem the
answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change
with it: a cache that can fail is no longer transparent.
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-cache-caffeine</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-core</artifactId>
</dependency>
<!--
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
amortised eviction are not a weekend's work to get right, and getting them wrong is a
cache that is slower than no cache.
-->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -1,41 +0,0 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheStats;
import java.util.function.Function;
/**
* {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly,
* adding no wrapper object, no copy and no synchronisation of its own.
*/
final class CaffeineCache<K, V> implements Cache<K, V> {
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
private final boolean statsRecorded;
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
this.delegate = delegate;
this.statsRecorded = statsRecorded;
}
@Override
public V get(K key, Function<? super K, ? extends V> loader) {
// Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key
// across concurrent callers; wrapping it in anything of ours would only add a race.
return delegate.get(key, loader);
}
@Override public V getIfPresent(K key) { return delegate.getIfPresent(key); }
@Override public void put(K key, V value) { delegate.put(key, value); }
@Override public void invalidate(K key) { delegate.invalidate(key); }
@Override public void invalidateAll() { delegate.invalidateAll(); }
@Override public long estimatedSize() { return delegate.estimatedSize(); }
@Override
public CacheStats stats() {
if (!statsRecorded) return CacheStats.DISABLED;
com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats();
return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount());
}
}
@@ -1,33 +0,0 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
/**
* Installs an in-process {@link CacheManager} backed by Caffeine.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new CaffeineCacheExtension())
* .scan("dev.example.api");
* }</pre>
*
* <p>No configuration. Each cache declares its own size and TTL where it is built, because those
* are properties of what is being cached, not of the process caching it.
*
* <p>Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its
* values alive — which matters when many apps start and stop in one JVM, as they do under test.
*/
public final class CaffeineCacheExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.supply(CacheManager.class, services -> {
CaffeineCacheManager manager = new CaffeineCacheManager();
services.onClose(manager::clear);
return manager;
});
}
}
@@ -1,66 +0,0 @@
package dev.relism.flash.ext.cache.caffeine;
import com.github.benmanes.caffeine.cache.Caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.cache.CacheSpec;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** In-process {@link CacheManager} backed by Caffeine. */
final class CaffeineCacheManager implements CacheManager {
private final Map<String, Entry> caches = new ConcurrentHashMap<>();
@Override
@SuppressWarnings("unchecked")
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> configure) {
CacheSpec spec = CacheSpec.of();
configure.accept(spec);
Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec)));
// Two handlers sharing a cache is the point; two handlers disagreeing about its size or
// TTL is a bug that would otherwise resolve to whichever one ran first.
String requested = describe(spec);
if (!entry.signature.equals(requested))
throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature
+ " but was requested as " + requested);
return (Cache<K, V>) entry.cache;
}
@Override
@SuppressWarnings("unchecked")
public <K, V> Cache<K, V> cache(String name) {
Entry entry = caches.get(name);
return entry == null ? null : (Cache<K, V>) entry.cache;
}
@Override
public Set<String> names() {
return Set.copyOf(caches.keySet());
}
/** Releases every entry so a stopped app does not keep its values alive. */
void clear() {
caches.values().forEach(entry -> entry.cache.invalidateAll());
caches.clear();
}
private static CaffeineCache<Object, Object> create(CacheSpec spec) {
Caffeine<Object, Object> builder = Caffeine.newBuilder();
if (spec.bounded()) builder.maximumSize(spec.maxSize());
if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl());
if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess());
if (spec.statsRecorded()) builder.recordStats();
return new CaffeineCache<>(builder.build(), spec.statsRecorded());
}
private static String describe(CacheSpec spec) {
return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl()
+ " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded();
}
private record Entry(String signature, CaffeineCache<Object, Object> cache) {}
}
@@ -1,160 +0,0 @@
package dev.relism.flash.ext.cache.caffeine;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.cache.CacheStats;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class CaffeineCacheTest {
private static final AtomicInteger loads = new AtomicInteger();
@RegisterExtension
static FlashTest app = FlashTest.of(configured -> {
configured.install(new CaffeineCacheExtension());
configured.ctx().onReady(() -> {
Cache<String, String> users = configured.ctx().require(CacheManager.class)
.build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats());
configured.get("/users/{id}", (req, res) ->
users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet()));
});
});
private static CacheManager manager() {
return app.app().ctx().require(CacheManager.class);
}
@Test
void aRepeatedRequestIsServedFromCache() {
String first = app.get("/users/alice").expectStatus(200).body();
String second = app.get("/users/alice").expectStatus(200).body();
assertEquals(first, second);
assertTrue(first.startsWith("loaded:alice:"));
}
@Test
void distinctKeysLoadSeparately() {
assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body());
}
@Test
void statsCountHitsAndMisses() {
Cache<String, String> cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats());
cache.get("k", key -> "v");
cache.get("k", key -> "v");
CacheStats stats = cache.stats();
assertEquals(1, stats.misses());
assertEquals(1, stats.hits());
assertEquals(0.5, stats.hitRate());
}
@Test
void statsAreDisabledUnlessAskedFor() {
Cache<String, String> cache = manager().build("no-stats", spec -> spec.maxSize(10));
cache.get("k", key -> "v");
assertEquals(CacheStats.DISABLED, cache.stats());
}
@Test
void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception {
Cache<String, String> cache = manager().build("single-flight", spec -> spec.maxSize(10));
AtomicInteger invocations = new AtomicInteger();
int threads = 16;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
Thread.ofVirtual().start(() -> {
try {
start.await();
cache.get("hot", key -> {
invocations.incrementAndGet();
return "value";
});
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
assertEquals(1, invocations.get(), "concurrent callers must share one load, not race");
}
@Test
void aNullLoaderResultStoresNothing() {
Cache<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
assertNull(cache.get("missing", key -> null));
assertNull(cache.getIfPresent("missing"));
}
@Test
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
Cache<String, String> cache = manager().build("invalidation", spec -> spec.maxSize(10));
cache.put("a", "1");
cache.put("b", "2");
cache.invalidate("a");
assertNull(cache.getIfPresent("a"));
assertEquals("2", cache.getIfPresent("b"));
cache.invalidateAll();
assertNull(cache.getIfPresent("b"));
}
@Test
void buildIsIdempotentPerName() {
Cache<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
Cache<String, String> second = manager().build("shared", spec -> spec.maxSize(10));
assertSame(first, second, "two handlers asking for one cache must get one cache");
assertSame(first, manager().cache("shared"));
}
@Test
void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() {
manager().build("contested", spec -> spec.maxSize(10));
IllegalStateException conflict = assertThrows(IllegalStateException.class,
() -> manager().build("contested", spec -> spec.maxSize(999)));
assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage());
}
@Test
void unknownNameReturnsNullRatherThanBuildingOne() {
assertNull(manager().cache("never-built"));
}
/** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */
@Test
void stoppingTheAppReleasesEveryCache() {
FlashApp standalone = FlashApp.create(FlashConfiguration.builder()
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build())
.install(new CaffeineCacheExtension());
standalone.start();
CacheManager manager = standalone.ctx().require(CacheManager.class);
manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v");
assertEquals(1, manager.names().size());
standalone.stop().join();
assertTrue(manager.names().isEmpty(), "caches must be released when the app stops");
}
}
@@ -1,64 +0,0 @@
# flash-ext-cache-core
The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to
nothing: it defines the abstractions and a backend implements them.
## Components
- `Cache<K, V>` — a named cache. `get(key, loader)` is the method that matters.
- `CacheManager` — creates and hands back named caches.
- `CacheSpec` — size and expiry for one cache.
- `CacheStats` — hit/miss/eviction counters.
Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md)
for in-process caching.
## The one shape that matters
```java
User user = users.get(id, repo::findById);
```
Compute-if-absent is the only cache operation most code needs, and the only one that is hard to
get right — the loader runs **once per key** across concurrent callers, and the rest wait rather
than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for
what it cannot express.
A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a
default; wrap it in an `Optional` or a sentinel if you want it.
## Naming and sharing
`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one
cache, not two, so nobody has to coordinate who creates it first.
If they disagree about the spec, that throws. The alternative is a cache whose size depends on
which handler happened to initialise first, which is the kind of bug that only shows up under
load.
## Specs
```java
CacheSpec.of()
.maxSize(10_000)
.ttl(Duration.ofMinutes(10))
.recordStats();
```
Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache
that never expires — a memory leak wearing a hat. Set at least one.
`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and
a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns
`CacheStats.DISABLED` rather than silently zero.
## Where the spec lives
On the cache, at the point it is built — not in application config. Size and TTL are properties of
*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL
nobody can relate back to the data it governs.
## Writing a backend
Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register
cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive.
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-cache-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -1,50 +0,0 @@
package dev.relism.flash.ext.cache;
import java.util.function.Function;
/**
* A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share
* across threads.
*
* <pre>{@code
* User user = users.get(id, repo::findById);
* }</pre>
*
* <p>{@link #get} is the only method most code needs. The rest exist for the cases it cannot
* express: reading without populating, writing a value computed elsewhere, and invalidating.
*
* @param <K> key type — must have a stable {@code hashCode}/{@code equals}
* @param <V> value type
*/
public interface Cache<K, V> {
/**
* Returns the cached value, computing and storing it with {@code loader} if absent.
*
* <p>The loader runs at most once per key across concurrent callers; the others wait for it
* rather than each computing their own. A loader returning {@code null} stores nothing and
* {@code null} is returned.
*/
V get(K key, Function<? super K, ? extends V> loader);
/** The cached value, or {@code null} if absent. Never invokes a loader. */
V getIfPresent(K key);
/** Stores {@code value}, replacing any existing entry. */
void put(K key, V value);
/** Drops {@code key}. Does nothing if it was absent. */
void invalidate(K key);
/** Drops every entry. */
void invalidateAll();
/** Approximate entry count. Approximate because eviction is asynchronous in most backends. */
long estimatedSize();
/**
* Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the
* backend was not asked to record them.
*/
CacheStats stats();
}
@@ -1,31 +0,0 @@
package dev.relism.flash.ext.cache;
import java.util.function.Consumer;
/**
* Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a
* backend extension such as {@code flash-ext-cache-caffeine} provides the implementation.
*
* <pre>{@code
* @Override protected void onInit() {
* users = require(CacheManager.class).build("users", spec -> spec
* .maxSize(10_000)
* .ttl(Duration.ofMinutes(10)));
* }
* }</pre>
*
* <p>{@link #build} is idempotent per name: calling it twice returns the same cache rather than
* two, so several handlers can share one without coordinating who creates it. The spec of the
* first call wins; a later call with a different spec is a configuration mistake and throws.
*/
public interface CacheManager {
/** Creates the named cache, or returns the existing one. */
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
<K, V> Cache<K, V> cache(String name);
/** Every cache name built so far, for an ops endpoint. */
java.util.Set<String> names();
}
@@ -1,64 +0,0 @@
package dev.relism.flash.ext.cache;
import java.time.Duration;
/**
* How one cache should behave. Every field is optional — a spec that sets nothing gives an
* unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one
* of {@link #maxSize} or {@link #ttl}.
*
* <pre>{@code
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
* }</pre>
*
* <p>Mutable builder rather than a record with {@code withX} copies: it is constructed once at
* boot inside a lambda and never shared.
*/
public final class CacheSpec {
private long maxSize = -1;
private Duration ttl;
private Duration ttlAfterAccess;
private boolean recordStats;
private CacheSpec() {}
public static CacheSpec of() {
return new CacheSpec();
}
/** Maximum entries before the backend starts evicting. Negative means unbounded. */
public CacheSpec maxSize(long maxSize) {
this.maxSize = maxSize;
return this;
}
/** Entries expire this long after they were written. */
public CacheSpec ttl(Duration ttl) {
this.ttl = ttl;
return this;
}
/** Entries expire this long after they were last read or written. */
public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) {
this.ttlAfterAccess = ttlAfterAccess;
return this;
}
/**
* Records hit/miss counters for {@link Cache#stats()}.
*
* <p>Off by default: counting costs a pair of atomic increments on every lookup, and a cache
* nobody is measuring should not pay for numbers nobody reads.
*/
public CacheSpec recordStats() {
this.recordStats = true;
return this;
}
public long maxSize() { return maxSize; }
public Duration ttl() { return ttl; }
public Duration ttlAfterAccess() { return ttlAfterAccess; }
public boolean statsRecorded() { return recordStats; }
public boolean bounded() { return maxSize >= 0; }
}
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.cache;
/**
* Hit/miss counters for one cache.
*
* @param hits lookups that found a value
* @param misses lookups that had to load
* @param evictions entries dropped to respect {@link CacheSpec#maxSize()}
*/
public record CacheStats(long hits, long misses, long evictions) {
/** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */
public static final CacheStats DISABLED = new CacheStats(0, 0, 0);
/** Hits divided by lookups, or 0 when nothing has been looked up yet. */
public double hitRate() {
long total = hits + misses;
return total == 0 ? 0 : (double) hits / total;
}
}
@@ -1,39 +0,0 @@
# flash-ext-cache-redis — planned
Not implemented. This directory holds the design so the decision is written down rather than
rediscovered; there is deliberately **no module, no pom and no source**, because an empty module
that builds an empty jar is dead weight in the reactor and in everyone's dependency tree.
Add it when there is a second replica that actually needs shared state.
## What it would implement
`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md),
so switching backend is an install-line change:
```java
.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379")))
```
## The part that is not a drop-in
`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in
ways an adapter cannot hide:
- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall
through to the loader and serve the value uncached, so Redis being down degrades throughput
rather than taking the application with it. That has to be a decision, not an accident.
- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec,
and the natural default is whatever `flash-ext-jackson` is already configured with.
- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key
prefix per cache name. The prefix is the right answer, and it means cache names become part of
the wire contract.
- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not,
because Redis evicts on its own policy.
## Why it is not built yet
Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing
a client library, a serialization format and a failure policy with no real usage to check them
against — and the failure policy in particular is the kind of decision that is wrong until a
production incident tells you otherwise.
@@ -1,125 +0,0 @@
# flash-ext-data-core
Shared core for Flash's data layer.
## Purpose
This module defines the transactional contract shared across backend implementations. It does not
talk to Hibernate or JDBC directly: it exposes abstractions and a minimal runtime, nothing else.
## Components
- `TxDefinition`: immutable transaction metadata.
- `TxStatus`: runtime state returned by the manager.
- `TxManager`: the `begin`/`commit`/`rollback` contract.
- `Tx`: runtime orchestration and the per-thread transaction stack.
- `ResourceRegistry`: thread-local storage for resources and synchronizations.
- `Repository<T, ID>`: self-transactional base repository.
- `Spec<T>`: composable predicate.
- `Query<T>`: query object carrying spec, sort and paging.
- `SpecBuilder<T>`: fluent DSL for building typed specs.
- `RepositorySupport<T, ID>`: shared internal helper.
- `TransactionPropagation`: propagation semantics.
- `TransactionIsolation`: isolation level.
- `TxSynchronization`: lifecycle hooks (see below).
## Execution model
The flow is:
1. `Tx.call(definition, work)` calls `TxManager.begin(definition)`.
2. The `TxManager` creates a backend-specific `TxStatus`.
3. The status is pushed onto the thread-local stack.
4. The work uses `Tx.resource(Class)` to obtain the current resource.
5. When the work ends, `Tx` chooses between `commit` and `rollback`.
6. The stack is popped, and the thread-local is cleared once it is empty.
## Supported propagation
- `REQUIRED`: use the active transaction, or open a new one.
- `REQUIRES_NEW`: suspend the current transaction and open a new one.
- `SUPPORTS`: join the active transaction if there is one, otherwise run without a transaction.
- `NOT_SUPPORTED`: suspend the current transaction and run without one.
- `MANDATORY`: require an active transaction.
## Synchronizations (`TxSynchronization`)
Lifecycle hooks for **one** transaction, registered through `Data.afterCommit(...)` (or directly
with `ResourceRegistry.addSynchronization(...)`).
Every callback belongs to exactly the innermost transaction active at registration time, and fires
exactly once, when *that* transaction completes:
- a **joined** inner transaction (`REQUIRED`) is not a transaction of its own, so callbacks
registered inside one wait for the outermost commit;
- a `REQUIRES_NEW` transaction is, so completing it fires only its own callbacks and leaves the
suspended outer transaction's pending.
### Which side of the commit each hook sits on
| hook | when | resource |
| --- | --- | --- |
| `beforeCommit(readOnly)` | immediately **before** the real commit | session/connection still **bound**, transaction still active |
| `afterCommit()` / `afterRollback()` | after completion | resource already **unbound** |
| `afterCompletion(outcome)` | after the two above | resource already unbound |
`beforeCommit` is the only hook that can still write through the same resource and have the write
land in the same atomic unit: flush a buffer, stamp an audit row, materialize a derived value. It
is skipped when the transaction is already `rollback-only`, since there is no commit to precede.
Post-completion callbacks run with the resource unbound instead: one that opens its own transaction
gets a **fresh** one rather than joining the transaction that just finished. That is what makes
them the right place to refresh a cache, enqueue a message, or notify anything outside the
database.
### Failure
Throwing from `beforeCommit` **vetoes the commit**: the transaction is rolled back,
`afterRollback`/`afterCompletion(ROLLED_BACK)` fire, and the exception reaches the caller. That is
the reason the hook runs before the commit rather than after — it can still refuse.
The post-completion hooks have no such power: the transaction is already over by the time they run,
so an exception propagates but changes nothing already committed, and stops the callbacks queued
behind it.
## Using `Repository`
`Repository` is the shared base for concrete repositories. Every public operation internally uses a
`REQUIRED` transaction, read-only where applicable.
Subclasses implement the `doXxx(...)` methods:
- `doFind(Query<T>)`
- `doFindOne(Spec<T>)`
- `doFindPage(Query<T>)`
- `doDeleteAll(Spec<T>)`
- `doUpdateAll(Spec<T>, T)`
The old `findAll(...)` and `findPage(...)` overloads were reduced to a combination of `Query<T>` and
`Spec<T>`.
```java
public abstract class Repository<T, ID> {
protected Repository(Tx tx) { ... }
protected final <R> R tx(Tx.TxCallable<R> work) { ... }
}
```
## Composing with Flash
`DataExtension` registers:
- `Tx` in the `FlashContext`
- `TxManager` in the `FlashContext`
- an annotation processor for `@Transactional`
This makes the data layer composable with Flash's extension system without global state.
## Implementation notes
- The transaction stack is thread-local and is cleared once it becomes empty.
- Backend resources are suspended and restored for `REQUIRES_NEW` and `NOT_SUPPORTED`.
- `TxSynchronization` is the hook point for commit/rollback/completion callbacks.
- Synchronizations live in a thread-local list; every new transaction records how many were already
registered when it opened and fires only its own tail, so a `REQUIRES_NEW` does not drag along
the suspended transaction's callbacks.
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-data-core</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>jakarta.transaction</groupId>
<artifactId>jakarta.transaction-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>jacoco-initialize</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-site</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
@@ -1,63 +0,0 @@
package dev.relism.flash.ext.data;
import dev.relism.flash.ext.data.core.Tx;
import dev.relism.flash.ext.data.core.Data;
import dev.relism.flash.ext.data.core.TxDefinition;
import dev.relism.flash.ext.data.core.TxManager;
import dev.relism.flash.ext.data.core.TransactionPropagation;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.routing.Middleware;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Objects;
public final class DataExtension implements FlashExtension {
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
private final TxManager txManager;
private final Tx tx;
private final Data data;
public DataExtension(TxManager txManager) {
this(txManager, null);
}
public DataExtension(TxManager txManager, Data data) {
this.txManager = Objects.requireNonNull(txManager);
this.data = data;
this.tx = data != null ? data.tx() : new Tx(txManager);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(Tx.class, tx);
ctx.provide(TxManager.class, txManager);
if (data != null) ctx.provide(Data.class, data);
ctx.addAnnotationProcessor(handlerClass -> {
Transactional ann = handlerClass.getAnnotation(Transactional.class);
if (ann == null) {
return List.of();
}
TxDefinition definition = TxDefinition.DEFAULTS
.withPropagation(mapTxType(ann.value()));
Middleware middleware = next -> (req, res) -> {
return tx.call(definition, () -> next.handle(req, res));
};
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
});
}
private TransactionPropagation mapTxType(Transactional.TxType txType) {
return switch (txType) {
case REQUIRED -> TransactionPropagation.REQUIRED;
case REQUIRES_NEW -> TransactionPropagation.REQUIRES_NEW;
case SUPPORTS -> TransactionPropagation.SUPPORTS;
case MANDATORY -> TransactionPropagation.MANDATORY;
case NOT_SUPPORTED, NEVER -> TransactionPropagation.NOT_SUPPORTED;
};
}
}
@@ -1,46 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.Objects;
import java.io.Serializable;
import java.util.concurrent.ConcurrentHashMap;
/**
* Application-facing data gateway. Repositories are created once per entity type and are safe to
* share: transaction/session state stays in {@link Tx}, never in the repository instance.
*/
public final class Data {
private final Tx tx;
private final RepositoryFactory repositories;
private final ConcurrentHashMap<Class<?>, Repository<?, ?>> cache = new ConcurrentHashMap<>();
public Data(Tx tx, RepositoryFactory repositories) {
this.tx = Objects.requireNonNull(tx, "tx");
this.repositories = Objects.requireNonNull(repositories, "repositories");
}
public Tx tx() { return tx; }
@SuppressWarnings("unchecked")
public <T, ID extends Serializable> Repository<T, ID> repository(Class<T> type) {
Objects.requireNonNull(type, "type");
return (Repository<T, ID>) cache.computeIfAbsent(type, key -> repositories.create(tx, type));
}
public void read(Tx.TxRunnable work) { tx.run(tx.readOnly(), work); }
public <T> T read(Tx.TxCallable<T> work) { return tx.call(tx.readOnly(), work); }
public void write(Tx.TxRunnable work) { tx.run(work); }
public <T> T write(Tx.TxCallable<T> work) { return tx.call(work); }
/** Transitional low-level access for infrastructure that needs an explicit definition. */
public void run(Tx.TxRunnable work) { tx.run(work); }
public <T> T call(Tx.TxCallable<T> work) { return tx.call(work); }
public <T> T call(TxDefinition definition, Tx.TxCallable<T> work) { return tx.call(definition, work); }
public TxDefinition readOnly() { return tx.readOnly(); }
/** Registers work that runs only after the enclosing write transaction commits. */
public void afterCommit(Runnable work) {
if (!tx.isActive()) throw new IllegalStateException("afterCommit requires an active transaction");
ResourceRegistry.addSynchronization(new TxSynchronization() {
@Override public void afterCommit() { work.run(); }
});
}
}
@@ -1,15 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.List;
public record Page<T>(
List<T> content,
int page,
int size,
long total
) {
public int totalPages() { return size == 0 ? 0 : (int) Math.ceil((double) total / size); }
public boolean hasNext() { return page + 1 < totalPages(); }
public boolean hasPrev() { return page > 0; }
public boolean isEmpty() { return content.isEmpty(); }
}
@@ -1,28 +0,0 @@
package dev.relism.flash.ext.data.core;
public record Query<T>(Spec<T> spec, Sort sort, Integer page, Integer size) {
public Query {
spec = spec == null ? Spec.all() : spec;
sort = sort == null ? Sort.unsorted() : sort;
}
public static <T> Query<T> all() {
return new Query<>(Spec.all(), Sort.unsorted(), null, null);
}
public Query<T> where(Spec<T> spec) {
return new Query<>(spec, sort, page, size);
}
public Query<T> orderBy(Sort sort) {
return new Query<>(spec, sort, page, size);
}
public Query<T> page(int page, int size) {
return new Query<>(spec, sort, page, size);
}
public boolean isPaged() {
return page != null && size != null;
}
}
@@ -1,121 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public abstract class Repository<T, ID> extends RepositorySupport<T, ID> {
protected Repository(Tx tx) {
super(tx);
}
public Optional<T> findById(ID id) {
return roQuery(() -> doFindById(id));
}
public boolean existsById(ID id) {
return roQuery(() -> doExistsById(id));
}
public long count() {
return roQuery(this::doCount);
}
public List<T> findAll() {
return findAll(Query.all());
}
public List<T> findAll(Spec<T> spec) {
return findAll(Query.<T>all().where(spec));
}
public List<T> findAll(Query<T> query) {
return roQuery(() -> doFind(query));
}
public Page<T> findPage(Query<T> query) {
return roQuery(() -> doFindPage(query));
}
public Optional<T> findOne(Spec<T> spec) {
return roQuery(() -> doFindOne(spec));
}
public T save(T entity) {
return rwQuery(() -> doSave(entity));
}
public T update(T entity) {
return rwQuery(() -> doUpdate(entity));
}
public List<T> saveAll(Iterable<T> entities) {
return rwQuery(() -> doSaveAll(entities));
}
public void delete(T entity) {
rwQuery(() -> {
doDelete(entity);
return null;
});
}
public void deleteById(ID id) {
rwQuery(() -> {
doDeleteById(id);
return null;
});
}
public int deleteAll(Spec<T> spec) {
return rwQuery(() -> doDeleteAll(spec));
}
public int updateAll(Spec<T> spec, T patch) {
return rwQuery(() -> doUpdateAll(spec, patch));
}
public List<T> findAll(int page, int size) {
return findAll(Query.<T>all().page(page, size));
}
public List<T> findAll(Sort sort) {
return findAll(Query.<T>all().orderBy(sort));
}
public List<T> findAll(int page, int size, Sort sort) {
return findAll(Query.<T>all().orderBy(sort).page(page, size));
}
public Page<T> findPage(int page, int size) {
return findPage(Query.<T>all().page(page, size));
}
public Page<T> findPage(int page, int size, Sort sort) {
return findPage(Query.<T>all().orderBy(sort).page(page, size));
}
public void deleteAll(Iterable<T> entities) {
rwQuery(() -> {
for (T entity : entities) {
doDelete(entity);
}
return null;
});
}
protected abstract Optional<T> doFindById(ID id);
protected abstract List<T> doFind(Query<T> query);
protected abstract Optional<T> doFindOne(Spec<T> spec);
protected abstract Page<T> doFindPage(Query<T> query);
protected abstract boolean doExistsById(ID id);
protected abstract long doCount();
protected abstract T doSave(T entity);
protected abstract List<T> doSaveAll(Iterable<T> entities);
protected abstract T doUpdate(T entity);
protected abstract void doDelete(T entity);
protected abstract void doDeleteById(ID id);
protected abstract int doDeleteAll(Spec<T> spec);
protected abstract int doUpdateAll(Spec<T> spec, T patch);
}
@@ -1,9 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.io.Serializable;
/** Creates the backend-specific, stateless repository for one entity type. */
@FunctionalInterface
public interface RepositoryFactory {
<T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type);
}
@@ -1,24 +0,0 @@
package dev.relism.flash.ext.data.core;
public abstract class RepositorySupport<T, ID> {
private final Tx tx;
private final TxDefinition rw = TxDefinition.DEFAULTS
.withPropagation(TransactionPropagation.REQUIRED);
private final TxDefinition ro = rw.asReadOnly();
protected RepositorySupport(Tx tx) {
this.tx = tx;
}
protected final Tx tx() {
return tx;
}
protected final <R> R roQuery(Tx.TxCallable<R> work) {
return tx.call(ro, work);
}
protected final <R> R rwQuery(Tx.TxCallable<R> work) {
return tx.call(rw, work);
}
}
@@ -1,111 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ResourceRegistry {
private static final ThreadLocal<Map<TxResourceKey, Object>> RESOURCES =
ThreadLocal.withInitial(HashMap::new);
private static final ThreadLocal<List<TxSynchronization>> SYNCHRONIZATIONS =
ThreadLocal.withInitial(ArrayList::new);
private ResourceRegistry() {}
public static void bind(TxResourceKey key, Object value) {
RESOURCES.get().put(key, Objects.requireNonNull(value));
}
public static boolean isBound(TxResourceKey key) {
return RESOURCES.get().containsKey(key);
}
public static void unbind(TxResourceKey key) {
RESOURCES.get().remove(key);
}
public static void clear() {
RESOURCES.get().clear();
SYNCHRONIZATIONS.get().clear();
}
public static void cleanup() {
RESOURCES.remove();
SYNCHRONIZATIONS.remove();
}
public static <R> R get(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key);
if (value == null) {
throw new IllegalStateException("No resource bound for key: " + key);
}
return type.cast(value);
}
public static <R> R getOrNull(TxResourceKey key, Class<R> type) {
Object value = RESOURCES.get().get(key);
return value == null ? null : type.cast(value);
}
public static void addSynchronization(TxSynchronization sync) {
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
}
/**
* How many synchronizations are registered right now — captured by a transaction manager when
* it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
* so that transaction only fires its own. See there for why that matters.
*/
public static int synchronizationCount() {
return SYNCHRONIZATIONS.get().size();
}
/**
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
* {@code fromIndex} onward, while their transaction is still active and its resource still
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
* that throws vetoes the commit, see {@link TxSynchronization}.
*
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
* which is the only coherent answer once the pass is already running.
*/
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
if (fromIndex >= pending.size()) {
return;
}
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
sync.beforeCommit(readOnly);
}
}
/**
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
* belonging to the transaction now completing. Everything before that index was registered by
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
* inner transaction sets its own baseline, so committing it no longer drags the outer's
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
* for an outer transaction that might still roll back.
*/
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
if (fromIndex >= pending.size()) {
return;
}
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
pending.subList(fromIndex, pending.size()).clear();
for (TxSynchronization sync : syncs) {
if (outcome == TxOutcome.COMMITTED) {
sync.afterCommit();
} else {
sync.afterRollback();
}
sync.afterCompletion(outcome);
}
}
}
@@ -1,26 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayList;
import java.util.List;
public record Sort(List<Column> columns) {
public record Column(String column, boolean asc) {}
public static Sort unsorted() { return new Sort(List.of()); }
public boolean isSorted() { return !columns.isEmpty(); }
public static Sort by(String column) { return new Sort(List.of(new Column(column, true))); }
public static Sort desc(String column) { return new Sort(List.of(new Column(column, false))); }
public static Sort by(String col, boolean asc){ return new Sort(List.of(new Column(col, asc))); }
public Sort then(String column) { return thenBy(column, true); }
public Sort thenDesc(String column) { return thenBy(column, false); }
private Sort thenBy(String column, boolean asc) {
List<Column> next = new ArrayList<>(columns);
next.add(new Column(column, asc));
return new Sort(next);
}
}
@@ -1,26 +0,0 @@
package dev.relism.flash.ext.data.core;
@FunctionalInterface
public interface Spec<T> {
String toFragment(SpecContext ctx);
default Spec<T> and(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " AND " + other.toFragment(ctx) + ")";
}
default Spec<T> or(Spec<T> other) {
return ctx -> "(" + this.toFragment(ctx) + " OR " + other.toFragment(ctx) + ")";
}
default Spec<T> not() {
return ctx -> "NOT (" + this.toFragment(ctx) + ")";
}
static <T> Spec<T> all() {
return ctx -> "1=1";
}
static <T> Spec<T> none() {
return ctx -> "1=0";
}
}
@@ -1,37 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.Collection;
import java.util.Objects;
import java.util.stream.Collectors;
public final class SpecBuilder<T> {
private SpecBuilder() {}
public static <T, V> FieldSpec<T, V> field(String column) {
return new FieldSpec<>(column);
}
public static final class FieldSpec<T, V> {
private final String column;
private FieldSpec(String column) {
this.column = Objects.requireNonNull(column);
}
public Spec<T> eq(V value) { return ctx -> column + " = " + ctx.bind(value); }
public Spec<T> neq(V value) { return ctx -> column + " != " + ctx.bind(value); }
public Spec<T> like(String pattern) { return ctx -> column + " like " + ctx.bind(pattern); }
public Spec<T> isNull() { return ctx -> column + " is null"; }
public Spec<T> isNotNull() { return ctx -> column + " is not null"; }
public Spec<T> in(Collection<V> values) {
return ctx -> column + " in (" + values.stream().map(ctx::bind).collect(Collectors.joining(", ")) + ")";
}
public <C extends Comparable<C>> Spec<T> gt(C value) { return ctx -> column + " > " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> lt(C value) { return ctx -> column + " < " + ctx.bind(value); }
public <C extends Comparable<C>> Spec<T> between(C lo, C hi) {
return ctx -> column + " between " + ctx.bind(lo) + " and " + ctx.bind(hi);
}
}
}
@@ -1,5 +0,0 @@
package dev.relism.flash.ext.data.core;
public interface SpecContext {
String bind(Object value);
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.data.core;
public enum TransactionIsolation {
DEFAULT(-1),
READ_UNCOMMITTED(1),
READ_COMMITTED(2),
REPEATABLE_READ(4),
SERIALIZABLE(8);
private final int level;
TransactionIsolation(int level) { this.level = level; }
public int level() { return level; }
}
@@ -1,9 +0,0 @@
package dev.relism.flash.ext.data.core;
public enum TransactionPropagation {
REQUIRED,
REQUIRES_NEW,
SUPPORTS,
NOT_SUPPORTED,
MANDATORY
}
@@ -1,116 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Objects;
public final class Tx {
private static final ThreadLocal<Deque<TxStatus>> STATUS_STACK =
ThreadLocal.withInitial(ArrayDeque::new);
private final TxManager manager;
public Tx(TxManager txManager) {
this.manager = Objects.requireNonNull(txManager);
}
public void run(TxRunnable work) {
run(TxDefinition.DEFAULTS, work);
}
public void run(TxDefinition definition, TxRunnable work) {
call(definition, () -> {
work.run();
return null;
});
}
public <T> T call(TxCallable<T> work) {
return call(TxDefinition.DEFAULTS, work);
}
public <T> T call(TxDefinition definition, TxCallable<T> work) {
TxStatus status = manager.begin(definition);
pushStatus(status);
try {
T result = work.call();
if (status.isRollbackOnly()) {
manager.rollback(status);
} else {
manager.commit(status);
}
return result;
} catch (Exception e) {
silentRollback(status);
throw (e instanceof TxException txException) ? txException : new TxException(e);
} catch (Throwable t) {
silentRollback(status);
throw sneakyThrow(t);
} finally {
popStatus();
if (STATUS_STACK.get().isEmpty()) {
STATUS_STACK.remove();
}
}
}
public boolean isActive() {
return !STATUS_STACK.get().isEmpty();
}
public void setRollbackOnly() {
currentStatus().markRollbackOnly();
}
public <R> R resource(Class<R> type) {
return currentStatus().resource(type);
}
public TxDefinition requiresNew() {
return TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW);
}
public TxDefinition readOnly() {
return TxDefinition.DEFAULTS.asReadOnly();
}
private TxStatus currentStatus() {
TxStatus status = STATUS_STACK.get().peek();
if (status == null) {
throw new IllegalStateException("No active transaction");
}
return status;
}
private void pushStatus(TxStatus status) {
STATUS_STACK.get().push(status);
}
private void popStatus() {
Deque<TxStatus> stack = STATUS_STACK.get();
if (!stack.isEmpty()) {
stack.pop();
}
}
private void silentRollback(TxStatus status) {
try {
manager.rollback(status);
} catch (Exception ignored) {
}
}
@SuppressWarnings("unchecked")
private static <E extends Throwable> RuntimeException sneakyThrow(Throwable t) throws E {
throw (E) t;
}
@FunctionalInterface
public interface TxRunnable {
void run();
}
@FunctionalInterface
public interface TxCallable<T> {
T call() throws Exception;
}
}
@@ -1,31 +0,0 @@
package dev.relism.flash.ext.data.core;
public record TxDefinition(
TransactionPropagation propagation,
TransactionIsolation isolation,
boolean readOnly,
String label
) {
public static final TxDefinition DEFAULTS = new TxDefinition(
TransactionPropagation.REQUIRED,
TransactionIsolation.DEFAULT,
false,
null
);
public TxDefinition withPropagation(TransactionPropagation p) {
return new TxDefinition(p, isolation, readOnly, label);
}
public TxDefinition withIsolation(TransactionIsolation i) {
return new TxDefinition(propagation, i, readOnly, label);
}
public TxDefinition withReadOnly(boolean ro) {
return new TxDefinition(propagation, isolation, ro, label);
}
public TxDefinition asReadOnly() {
return new TxDefinition(propagation, isolation, true, label);
}
}
@@ -1,6 +0,0 @@
package dev.relism.flash.ext.data.core;
public class TxException extends RuntimeException {
public TxException(String message) { super(message); }
public TxException(Throwable cause) { super(cause); }
}
@@ -1,7 +0,0 @@
package dev.relism.flash.ext.data.core;
public interface TxManager {
TxStatus begin(TxDefinition definition);
void commit(TxStatus status);
void rollback(TxStatus status);
}
@@ -1,6 +0,0 @@
package dev.relism.flash.ext.data.core;
public enum TxOutcome {
COMMITTED,
ROLLED_BACK
}
@@ -1,32 +0,0 @@
package dev.relism.flash.ext.data.core;
import java.util.Objects;
public final class TxResourceKey {
private final String name;
private TxResourceKey(String name) {
this.name = Objects.requireNonNull(name);
}
public static TxResourceKey of(String name) {
return new TxResourceKey(name);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TxResourceKey that)) return false;
return name.equals(that.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public String toString() {
return name;
}
}
@@ -1,9 +0,0 @@
package dev.relism.flash.ext.data.core;
public interface TxStatus {
boolean isNewTransaction();
boolean isReadOnly();
boolean isRollbackOnly();
void markRollbackOnly();
<R> R resource(Class<R> type);
}
@@ -1,58 +0,0 @@
package dev.relism.flash.ext.data.core;
/**
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
* owns the transaction they were registered in.
*
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
*
* <h3>Where each hook sits relative to the commit</h3>
* <ul>
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
* transaction still active and its session/connection still bound. This is the only hook
* that can still write through that same resource and have the write land in the same atomic
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
* when the transaction is already rollback-only, since there is no commit to precede.</li>
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
* done here is part of the transaction: a callback that opens its own transaction gets a
* fresh one instead of joining the one that just finished, which is what makes this the
* right place to refresh a cache, enqueue a message, or notify anything outside the
* database.</li>
* </ul>
*
* <h3>Failure</h3>
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
* this hook running before the commit rather than after — it can still refuse.
*
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
* so an exception from one propagates to the caller but changes nothing already committed, and
* stops the callbacks queued behind it.
*/
public interface TxSynchronization {
/**
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
* Throwing from here rolls the transaction back instead of committing it.
*
* @param readOnly whether the transaction was opened read-only, so a callback that would
* otherwise write can skip work it is not allowed to do
*/
default void beforeCommit(boolean readOnly) {}
/** Runs after a successful commit, with the transaction's resource already unbound. */
default void afterCommit() {}
/** Runs after a rollback, with the transaction's resource already unbound. */
default void afterRollback() {}
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
default void afterCompletion(TxOutcome outcome) {}
}
@@ -1,95 +0,0 @@
# flash-ext-data-hibernate
Hibernate backend for `flash-ext-data-core`.
## Purpose
This module implements `TxManager` on top of a `SessionFactory` and provides a Hibernate-centric
repository base class.
## How to use it
### 1. Create the manager
```java
SessionFactory sessionFactory = ...;
HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
DataExtension extension = new DataExtension(txManager);
```
### 2. Install the extension in Flash
The extension registers `Tx` and `TxManager` in the `FlashContext`. Class-based handlers annotated
with `@Transactional` are wrapped automatically.
### 3. Define a repository
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public UserRepository(Tx tx) {
super(tx, User.class);
}
}
```
With the query/spec model you can expose reusable fields as constants:
```java
public final class UserRepository extends HibernateRepository<User, Long> {
public static final SpecBuilder.FieldSpec<User, String> EMAIL = SpecBuilder.field("u.email");
public static final SpecBuilder.FieldSpec<User, Boolean> ACTIVE = SpecBuilder.field("u.active");
public UserRepository(Tx tx) {
super(tx, User.class);
}
public Optional<User> findByEmail(String email) {
return findOne(EMAIL.eq(email));
}
}
```
Domain-specific queries can use the base class helpers:
```java
public List<User> findByEmailDomain(String domain) {
return findMany("from User u where u.email like :email", q ->
q.setParameter("email", "%@" + domain)
);
}
```
## How it works underneath
- The current transaction is represented by `HibernateTxStatus`.
- The resource exposed to the core is a `Session`.
- `Tx.resource(Session.class)` retrieves the `Session` from the current context.
- `REQUIRES_NEW` suspends the active status and opens a new `Session`.
- `NOT_SUPPORTED` suspends the active transaction and continues with no session bound.
## Repository base class
`HibernateRepository` provides:
- `findById`, `findAll`, `findPage`, `findOne`
- `save`, `update`, `delete`, `saveAll`
- bulk `deleteAll(Spec<T>)` and `updateAll(Spec<T>, T)`
- HQL helpers: `hql(...)`, `hqlMutate(...)`
Concrete classes only have to implement domain queries, never the transactional plumbing.
## Transactional semantics
- `REQUIRED`: join, or open a new transaction.
- `REQUIRES_NEW`: suspend the current context.
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
- `NOT_SUPPORTED`: suspend and continue without a transaction.
- `MANDATORY`: fail if there is no transaction.
## Notes
- The `Session` is closed when a new transaction ends.
- Synchronizations registered in a transaction fire when *that* transaction completes:
`beforeCommit` while it is still active and the `Session` still bound, the post-completion hooks
once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
- This backend is meant to be used through the base class, not directly.

Some files were not shown because too many files have changed in this diff Show More