Hotfix 2026.10.26.4: OpenID Connect audience in the console #158

Merged
jcoffey-dev merged 5 commits from hotfix/oidc-audience into release/2026 2026-11-01 16:21:28 +00:00
12 changed files with 159 additions and 13 deletions

No files matched your search

+12 -1
View File
@@ -121,6 +121,7 @@ jobs:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
@@ -133,6 +134,16 @@ jobs:
exit 1
fi
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
# :latest belongs to the newest series: once a final release numbered
# by series exists (v27.1, v27.1.3), a dated release from release/2026
# leaves it alone. A failed check stops here rather than guess.
releases="$(curl -fsS -H "Authorization: token $READ_TOKEN" "$CI_SERVER_INTERNAL/api/v1/repos/$GITHUB_REPOSITORY/releases?draft=false&pre-release=false&limit=50")"
series="$(printf '%s' "$releases" | jq -er '[.[].tag_name | select(test("^v[0-9]{2}\\."))] | first // ""')"
if [ -n "$series" ]; then
echo "$series is out: :latest stays on its series"; echo "LATEST_TAG=" >> "$GITHUB_ENV"
else
echo "no series release yet: :latest moves"; echo "LATEST_TAG=--tag $IMAGE:latest" >> "$GITHUB_ENV"
fi
- run: |
test -n "$REGISTRY"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
@@ -146,7 +157,7 @@ jobs:
--platform linux/amd64,linux/arm64 \
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
$LATEST_TAG \
--push .
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
+30 -6
View File
@@ -101,6 +101,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
latest: ${{ steps.latest.outputs.latest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: v
@@ -112,6 +113,20 @@ jobs:
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# :latest belongs to the newest series. Once a final release numbered by
# series exists (v27.1, v27.1.3), a dated release from release/2026
# leaves :latest and GitHub's latest release where they are. The check
# fails rather than guess: a wrong :latest is worse than a re-run.
- id: latest
run: |
set -euo pipefail
url="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases?draft=false&pre-release=false&limit=50"
series="$(curl -fsS "$url" | jq -er '[.[].tag_name | select(test("^v[0-9]{2}\\."))] | first // ""')"
if [ -n "$series" ]; then
echo "$series is out: :latest stays on its series"; echo "latest=false" >> "$GITHUB_OUTPUT"
else
echo "no series release yet: :latest moves"; echo "latest=true" >> "$GITHUB_OUTPUT"
fi
# Each architecture on its own native runner, pushed as an untagged image by
# digest; `publish` joins the two digests into one multi-arch tag.
@@ -165,7 +180,8 @@ jobs:
overwrite: true
if-no-files-found: error
# Joins the digests into `:<version>` and `:latest`, and links the package
# Joins the digests into `:<version>`, and `:latest` while the version job
# says so, and links the package
# to the repository on Gitea. The release itself already exists: Gitea's
# weekly-release.yml made it, which is what made the tag.
publish:
@@ -187,10 +203,12 @@ jobs:
- name: Create the manifest
env:
VERSION: ${{ needs.version.outputs.version }}
LATEST: ${{ needs.version.outputs.latest }}
run: |
refs=()
for f in /tmp/digests/*; do refs+=("${IMAGE}@sha256:$(basename "$f")"); done
docker buildx imagetools create -t "${IMAGE}:${VERSION}" -t "${IMAGE}:latest" "${refs[@]}"
tags=(-t "${IMAGE}:${VERSION}"); if [ "$LATEST" = true ]; then tags+=(-t "${IMAGE}:latest"); fi
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
# Shows the package on the repository's Packages tab. Idempotent.
- env:
@@ -250,7 +268,7 @@ jobs:
# ------------------------------------------------------ ghcr replica ------
# Copies the release image from the Gitea registry, which stays the
# authoritative one, to ghcr.io under the same version tag and :latest. It is
# authoritative one, to ghcr.io under the same version tag, and :latest as above. It is
# a copy, not a second build: the digest on GHCR is the digest on the
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
# out of the report to Gitea, like the release copy, so a GHCR problem
@@ -266,13 +284,15 @@ jobs:
- env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.version.outputs.version }}
LATEST: ${{ needs.version.outputs.latest }}
run: |
set -euo pipefail
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
tag="$TAG"
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
args=(-t "$dst:$tag"); if [ "$LATEST" = true ]; then args+=(-t "$dst:latest"); fi
docker buildx imagetools create "${args[@]}" "$src:$tag"
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
echo "registry $src:$tag = $want"
@@ -286,9 +306,12 @@ jobs:
# release is the real one; this is left out of the report to Gitea, so a
# failure here cannot fail a release. PR and issue numbers in the notes are
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
# It waits for `files` too: started after `publish` alone, it ran beside
# `files` and copied the Gitea release before its files were attached, so
# every GitHub copy had none.
github-release:
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
needs: [publish]
needs: [version, publish, files]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -296,6 +319,7 @@ jobs:
GITEA_URL: ${{ vars.GITEA_URL }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
LATEST: ${{ needs.version.outputs.latest }}
steps:
- run: |
set -euo pipefail
@@ -322,7 +346,7 @@ jobs:
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; elif [ "$LATEST" = true ]; then kind=--latest; else kind=--latest=false; fi
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
--notes-file notes.md "$kind" "${files[@]}"
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "2026.10.26.3"
"version": "2026.10.26.4"
}
+11 -1
View File
@@ -98,6 +98,7 @@ export function DirectorySetupPage() {
bindPassword: '',
allowInvalidCerts: false,
issuer: '',
audience: '',
usernameDomain: '',
});
const [savedId, setSavedId] = useState<string | null>(null);
@@ -307,7 +308,7 @@ export function DirectorySetupPage() {
? /^ldaps?:\/\//.test(conn.url.trim()) &&
conn.baseDn.trim() !== '' &&
(savedId !== null || conn.bindDn.trim() === '' || conn.bindPassword !== '')
: /^https:\/\//.test(conn.issuer.trim());
: /^https:\/\//.test(conn.issuer.trim()) && conn.audience.trim() !== '';
return (
<WizardShell
{...common}
@@ -377,6 +378,15 @@ export function DirectorySetupPage() {
) : (
<>
{field('issuer', t('dirSetup.issuer', 'Issuer URL'), preset.example.issuer)}
{field(
'audience',
t('dirSetup.audience', 'Client ID (audience)'),
'inbuxa',
t(
'dirSetup.audienceHint',
'The client ID inbuxa is registered with at the provider. Only tokens issued for it sign in, not tokens the provider gave other applications.',
),
)}
{field(
'usernameDomain',
t('dirSetup.usernameDomain', 'Domain for plain user names (optional)'),
+8 -2
View File
@@ -14,6 +14,7 @@ const conn: Connection = {
bindPassword: 'pw',
allowInvalidCerts: false,
issuer: '',
audience: '',
usernameDomain: '',
};
@@ -39,13 +40,18 @@ describe('directoryObject', () => {
expect(directoryObject(presetById('openldap'), { ...conn, bindPassword: '' }, '')).not.toHaveProperty('bindSecret');
});
it('builds an OIDC directory from the issuer', () => {
it('builds an OIDC directory from the issuer and the audience', () => {
expect(
directoryObject(presetById('keycloak'), { ...conn, issuer: ' https://sso.example.org/realms/x ' }, 'KC'),
directoryObject(
presetById('keycloak'),
{ ...conn, issuer: ' https://sso.example.org/realms/x ', audience: ' inbuxa ' },
'KC',
),
).toEqual({
'@type': 'Oidc',
description: 'KC',
issuerUrl: 'https://sso.example.org/realms/x',
requireAudience: 'inbuxa',
usernameDomain: null,
claimUsername: 'email',
claimName: 'name',
+3
View File
@@ -139,6 +139,8 @@ export interface Connection {
bindPassword: string;
allowInvalidCerts: boolean;
issuer: string;
/** The client ID registered for this server at the provider: a token must name it as its audience. */
audience: string;
usernameDomain: string;
}
@@ -149,6 +151,7 @@ export function directoryObject(p: Preset, c: Connection, description: string):
'@type': 'Oidc',
description,
issuerUrl: c.issuer.trim(),
requireAudience: c.audience.trim(),
usernameDomain: c.usernameDomain.trim() || null,
...p.fields,
};
+21
View File
@@ -179,4 +179,25 @@ describe('the checks (acceptance tests 1-8)', () => {
const locked = { ...fresh(), metrics: { prometheus: { '@type': 'Enabled', authSecret: '********' } } };
expect(ids(locked)).not.toContain('SS-12');
});
it('OpenID Connect directories without an audience, or taking opaque tokens on user info, are flagged', () => {
const dir = { oidc: true, requireAudience: 'inbuxa', opaqueTokens: null };
const s: Snapshot = {
...fresh(),
directories: [
{ ...dir, id: 'a', name: 'Keycloak' },
{ ...dir, id: 'b', name: 'Old Keycloak', requireAudience: null, opaqueTokens: 'userinfo' },
{ ...dir, id: 'c', name: 'Introspected', opaqueTokens: 'introspection' },
{ ...dir, id: 'd', name: 'LDAP', oidc: false, requireAudience: null },
],
};
const items = evaluate(s, defaults).items.filter((i) => i.check === 'SS-30' || i.check === 'SS-31');
expect(items.map((i) => [i.check, i.subject])).toEqual([
['SS-30', 'b'],
['SS-31', 'b'],
]);
expect(items[0].title).toContain('Old Keycloak');
expect(items[0].action).toEqual({ kind: 'review', viewName: 'x:Directory', id: 'b' });
expect(ids(fresh())).not.toContain('SS-30');
});
});
+44 -1
View File
@@ -86,6 +86,17 @@ export interface DomainSnapshot {
dmarcPolicy: string | null;
}
/** An external directory, as SS-30 and SS-31 read it. */
export interface DirectorySnapshot {
id: string;
name: string;
/** OpenID Connect: the only kind these checks look at. */
oidc: boolean;
requireAudience: string | null;
/** reject, userinfo or introspection; null when not set. */
opaqueTokens: string | null;
}
export interface Snapshot {
now: number;
imap?: { allowPlainTextAuth?: boolean };
@@ -101,6 +112,7 @@ export interface Snapshot {
metrics?: { prometheus?: { '@type'?: string; authSecret?: unknown } };
mtaSts?: { mode?: string };
domains?: DomainSnapshot[];
directories?: DirectorySnapshot[];
/** The legacy-protocols switch: true while any legacy protocol is on. */
legacyOn?: boolean;
/** Why a part couldn't be read, by part name. */
@@ -122,7 +134,8 @@ export type Part =
| 'mtaSts'
| 'domains'
| 'dns'
| 'legacy';
| 'legacy'
| 'directories';
/** The schema's defaults, as the console knows them. */
export type Defaults = (object: string, field: string) => unknown;
@@ -551,6 +564,36 @@ export function evaluate(s: Snapshot, defaults: Defaults): Result {
check('SS-14', 'Certificates expiring within 30 days', ['certificates'], certItems(30 * DAY, 'important', 'SS-14'));
// SS-30 and SS-31 are numbered past the spec's requirements (SS-19 to
// SS-29), which share the numbering.
check('SS-30', 'OpenID Connect directories without an audience', ['directories'], () =>
(s.directories ?? [])
.filter((d) => d.oidc && !d.requireAudience)
.map((d) => ({
check: 'SS-30',
severity: 'important' as const,
subject: d.id,
value: null,
title: `${d.name || d.id} accepts tokens issued to other applications`,
why: 'It names no required audience, so a token its provider issued to any other application signs people in here. Set it to the client ID inbuxa is registered with at the provider. A later release will require it.',
action: { kind: 'review' as const, viewName: 'x:Directory', id: d.id },
})),
);
check('SS-31', 'Opaque tokens taken on the user info endpoint’s word', ['directories'], () =>
(s.directories ?? [])
.filter((d) => d.oidc && d.opaqueTokens === 'userinfo')
.map((d) => ({
check: 'SS-31',
severity: 'important' as const,
subject: d.id,
value: 'userinfo',
title: `${d.name || d.id} accepts any token its provider issued`,
why: 'A token that isn’t a JWT signs in whenever the provider’s user info endpoint answers for it, and it does for tokens issued to any application. Check them by introspection, with the client ID and secret inbuxa has at the provider, or reject them.',
action: { kind: 'review' as const, viewName: 'x:Directory', id: d.id },
})),
);
// Good practice
check('SS-15', 'Legacy mail protocols', ['legacy'], () =>
+15
View File
@@ -64,6 +64,12 @@ const CALLS: Call[] = [
{ part: 'senderAuth', method: 'x:SenderAuth/get', ids: ['singleton'], properties: ['dmarcVerify'] },
{ part: 'metrics', method: 'x:Metrics/get', ids: ['singleton'], properties: ['prometheus'] },
{ part: 'mtaSts', method: 'x:MtaSts/get', ids: ['singleton'], properties: ['mode'] },
{
part: 'directories',
method: 'x:Directory/get',
ids: null,
properties: ['@type', 'description', 'requireAudience', 'opaqueTokens'],
},
{
part: 'domains',
method: 'x:Domain/get',
@@ -166,6 +172,15 @@ export async function loadSnapshot(signal?: AbortSignal, critical = false): Prom
};
});
}
if (lists.directories) {
snapshot.directories = lists.directories.map((d) => ({
id: String(d.id),
name: String(d.description ?? ''),
oidc: d['@type'] === 'Oidc',
requireAudience: typeof d.requireAudience === 'string' ? d.requireAudience : null,
opaqueTokens: typeof d.opaqueTokens === 'string' ? d.opaqueTokens : null,
}));
}
if (lists.tlsStrategies) {
snapshot.tlsStrategies = lists.tlsStrategies.map((t) => ({
id: String(t.id),
+1
View File
@@ -24,6 +24,7 @@ const PARTS: Part[] = [
'domains',
'dns',
'legacy',
'directories',
];
describe('security page help', () => {
+10
View File
@@ -103,6 +103,16 @@ export const SECURITY_CHECK_HELP: { check: string; label: string; text: string }
label: 'DMARC policies',
text: 'Good practice for each domain whose DMARC record says p=none. Once DMARC reports show your own mail passing, move to quarantine or reject at your DNS host.',
},
{
check: 'SS-30',
label: 'OpenID Connect directories without an audience',
text: 'Important for each OpenID Connect directory saved without a required audience (a new one can’t be). A token its provider issued to any other application signs people in here. Review opens the directory: set the audience to the client ID inbuxa is registered with at the provider.',
},
{
check: 'SS-31',
label: 'Opaque tokens taken on the user info endpoint’s word',
text: 'Important for each OpenID Connect directory that accepts tokens which aren’t JWTs whenever the provider’s user info endpoint answers for them, as directories saved before the choice existed do. Review opens the directory: choose introspection, with inbuxa’s client ID and secret at the provider, or reject.',
},
];
export const SECURITY_PAGE_HELP: PageHelp = {
+3 -1
View File
@@ -887,7 +887,9 @@
"defaultMoves": "Every other domain without its own directory signs in through {{dir}}.",
"done": "Sign-in now goes through {{dir}}.",
"doneHint": "Have someone sign in to the webmail with their directory password. Their account is created on first sign-in, with the name, aliases and groups the directory gives.",
"tabTitle": "Sign-in directory"
"tabTitle": "Sign-in directory",
"audience": "Client ID (audience)",
"audienceHint": "The client ID inbuxa is registered with at the provider. Only tokens issued for it sign in, not tokens the provider gave other applications."
},
"held": {
"noSubject": "(no subject)",