id: oidc-signing-key-rollover-guard
namespace: company.team
description: Check a trusted OIDC provider's signing keys before removing a key
or deploying clients that depend on it.
inputs:
- id: discovery_url
type: STRING
description: Full HTTPS URL of the OIDC discovery document. Verify this URL
through your identity provider.
defaults: https://accounts.google.com/.well-known/openid-configuration
- id: expected_issuer
type: STRING
description: Exact issuer claim used by your OIDC clients. The discovery issuer
must match it.
defaults: https://accounts.google.com
- id: jwks_url
type: STRING
description: Approved HTTPS key-set URL. The discovery document must name this
exact URL.
defaults: https://www.googleapis.com/oauth2/v3/certs
- id: required_kids
type: STRING
description: Comma-separated key IDs that must remain available during your
planned overlap window. Get the IDs from a trusted client or rollout
manifest.
- id: minimum_signing_keys
type: INT
defaults: 1
description: Minimum number of usable public signing keys, from 1 to 100.
triggers:
- id: periodic_key_check
type: io.kestra.plugin.core.trigger.Schedule
description: Disabled until the URLs, approved key IDs, Slack webhook, and BLOCK
handling have been tested.
cron: "0 */6 * * *"
disabled: true
tasks:
- id: get_discovery
type: io.kestra.plugin.core.http.Request
description: Fetch the provider's issuer and JWKS URL through its HTTPS
discovery endpoint.
uri: "{{ inputs.discovery_url }}"
method: GET
options:
followRedirects: false
headers:
Accept: application/json
- id: get_key_set
type: io.kestra.plugin.core.http.Request
description: Fetch the key set at the independently configured URL; never follow
a JWKS URL chosen only by a remote discovery response.
uri: "{{ inputs.jwks_url }}"
method: GET
options:
followRedirects: false
headers:
Accept: application/json
- id: assess_key_overlap
type: io.kestra.plugin.scripts.node.Script
description: Check issuer and JWKS URL pins, public signing keys, duplicate
identifiers and the required key IDs. Save the evidence before the gate.
containerImage: node:22-alpine
env:
DISCOVERY_URL: "{{ inputs.discovery_url }}"
EXPECTED_ISSUER: "{{ inputs.expected_issuer }}"
JWKS_URL: "{{ inputs.jwks_url }}"
REQUIRED_KIDS: "{{ inputs.required_kids }}"
MINIMUM_SIGNING_KEYS: "{{ inputs.minimum_signing_keys }}"
inputFiles:
discovery.json: "{{ outputs.get_discovery.body }}"
jwks.json: "{{ outputs.get_key_set.body }}"
outputFiles:
- oidc-key-rollover-report.json
script: |
const fs = require('fs');
const path = require('path');
const MAX_DOCUMENT_BYTES = 1024 * 1024;
function object(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function readJson(filename) {
const raw = fs.readFileSync(path.join(process.env.WORKING_DIR, filename), 'utf8');
if (Buffer.byteLength(raw, 'utf8') > MAX_DOCUMENT_BYTES) throw Error('Oversized OIDC document');
let doc = JSON.parse(raw);
if (typeof doc === 'string') doc = JSON.parse(doc);
if (!object(doc)) throw Error('OIDC document must be a JSON object');
return doc;
}
function httpsUrl(value, label) {
if (typeof value !== 'string' || value.length > 2048) throw Error('Invalid ' + label);
let url;
try { url = new URL(value); } catch { throw Error('Invalid ' + label); }
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.hash)
throw Error(label + ' must be HTTPS without credentials or fragment');
return value;
}
function requiredKeys(raw) {
if (typeof raw !== 'string' || raw.length > 8192) throw Error('Invalid required_kids input');
const kids = raw.split(',').map(x => x.trim());
if (!kids.length || kids.length > 100 || kids.some(x => !x || x.length > 256))
throw Error('Provide 1-100 nonempty required key IDs');
if (new Set(kids).size !== kids.length) throw Error('Duplicate required key ID');
return kids;
}
function signingKey(key) {
if (!object(key) || typeof key.kid !== 'string' || !key.kid || key.kid.length > 256)
return false;
if (key.use !== undefined && key.use !== 'sig') return false;
if (key.key_ops !== undefined && (!Array.isArray(key.key_ops) || !key.key_ops.includes('verify')))
return false;
// An explicit JWK alg must be a JWS signature algorithm for this key type.
// JWE key-management algorithms (such as RSA-OAEP and ECDH-ES) cannot sign.
const signingAlgorithms = {
RSA: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'],
EC: ['ES256', 'ES384', 'ES512', 'ES256K'],
OKP: ['EdDSA', 'Ed25519', 'Ed448']
};
const allowedAlgorithms = Object.hasOwn(signingAlgorithms, key.kty) ? signingAlgorithms[key.kty] : [];
if (key.alg !== undefined && !allowedAlgorithms.includes(key.alg)) return false;
if (key.kty === 'RSA') return typeof key.n === 'string' && !!key.n && typeof key.e === 'string' && !!key.e;
if (key.kty === 'EC') {
const curves = {'P-256': 'ES256', 'P-384': 'ES384', 'P-521': 'ES512', 'secp256k1': 'ES256K'};
const expected = typeof key.crv === 'string' && Object.hasOwn(curves, key.crv) ? curves[key.crv] : null;
return !!expected && (key.alg === undefined || key.alg === expected) &&
typeof key.x === 'string' && !!key.x && typeof key.y === 'string' && !!key.y;
}
if (key.kty === 'OKP') return (key.crv === 'Ed25519' || key.crv === 'Ed448') &&
(key.alg === undefined || key.alg === 'EdDSA' || key.alg === key.crv) &&
typeof key.x === 'string' && !!key.x;
return false;
}
function assess(discovery, jwks, config) {
const reasons = [];
if (discovery.issuer !== config.expectedIssuer) reasons.push('Discovery issuer differs from the expected issuer');
if (discovery.jwks_uri !== config.jwksUrl) reasons.push('Discovery JWKS URL differs from the approved URL');
if (!Array.isArray(jwks.keys) || jwks.keys.length > 500) throw Error('Invalid JWKS keys array');
const accepted = jwks.keys.filter(signingKey);
const ids = accepted.map(key => key.kid);
if (new Set(ids).size !== ids.length) reasons.push('Duplicate signing key IDs');
if (ids.length < config.minimumSigningKeys) reasons.push('Too few usable public signing keys');
const missing = config.requiredKids.filter(kid => !ids.includes(kid));
if (missing.length) reasons.push('Required signing key IDs are absent');
return {
status: reasons.length ? 'BLOCK' : 'PASS',
issuer: config.expectedIssuer,
jwks_url: config.jwksUrl,
keys_returned: jwks.keys.length,
public_signing_keys: accepted.length,
required_kids: config.requiredKids,
present_signing_kids: ids,
missing_required_kids: missing,
reasons
};
}
function main() {
if (!process.env.WORKING_DIR) throw Error('Kestra working directory is not set');
httpsUrl(process.env.DISCOVERY_URL, 'discovery_url');
const issuer = httpsUrl(process.env.EXPECTED_ISSUER, 'expected_issuer');
const jwksUrl = httpsUrl(process.env.JWKS_URL, 'jwks_url');
const requiredKids = requiredKeys(process.env.REQUIRED_KIDS);
const min = Number(process.env.MINIMUM_SIGNING_KEYS);
if (!Number.isSafeInteger(min) || min < 1 || min > 100) throw Error('minimum_signing_keys must be 1-100');
const result = assess(readJson('discovery.json'), readJson('jwks.json'), {
expectedIssuer: issuer, jwksUrl, requiredKids, minimumSigningKeys: min
});
fs.writeFileSync(path.join(process.env.WORKING_DIR, 'oidc-key-rollover-report.json'), JSON.stringify(result, null, 2));
console.log('::' + JSON.stringify({outputs: {
status: result.status,
missing_count: result.missing_required_kids.length,
signing_key_count: result.public_signing_keys,
reason_count: result.reasons.length
}}) + '::');
console.log('OIDC key rollover preflight: ' + result.status);
}
if (require.main === module) main();
- id: publish_report
type: io.kestra.plugin.core.output.OutputValues
description: Publish the verdict and complete JSON evidence before a BLOCK fails
the execution.
values:
status: "{{ outputs.assess_key_overlap.vars.status }}"
report_uri: "{{
outputs.assess_key_overlap.outputFiles['oidc-key-rollover-report.json']
}}"
missing_count: "{{ outputs.assess_key_overlap.vars.missing_count }}"
- id: key_overlap_gate
type: io.kestra.plugin.core.flow.If
description: Stop downstream deployments when the issuer, key source, or
required signing-key overlap is not safe.
condition: "{{ outputs.assess_key_overlap.vars.status == 'BLOCK' }}"
then:
- id: alert_key_overlap
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Report a blocked rollover. The saved JSON report contains the exact
reasons.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{"text":"OIDC signer rollover BLOCKED for {{ inputs.expected_issuer }}. Missing key IDs: {{ outputs.assess_key_overlap.vars.missing_count }}. Findings: {{ outputs.assess_key_overlap.vars.reason_count }}. Execution: {{ execution.id }}. Review the JSON report."}
- id: stop_deployment
type: io.kestra.plugin.core.execution.Fail
description: Fail the preflight so dependent releases cannot interpret missing
keys as a passing check.
errorMessage: "OIDC key rollover preflight BLOCKED. Review
oidc-key-rollover-report.json."
else:
- id: log_overlap_ready
type: io.kestra.plugin.core.log.Log
message: "OIDC key overlap PASS for {{ inputs.expected_issuer }}. Required key
IDs were present at check time."
errors:
- id: log_failed_check
type: io.kestra.plugin.core.log.Log
message: "OIDC key rollover check failed or blocked; no deployment approval.
Execution {{ execution.id }}."