Files
gradle/sources/src/develocity/short-lived-token.ts
T
Daz DeBoerandClaude Opus 5 02d4784b6e Relax Develocity access key format validation (#1061)
Develocity access keys have the format `server=key[;server=key]*`. The
short-lived-token code validated that format with:

```
/^([^;=\s]+=\w+)(;[^;=\s]+=\w+)*$/
```

This is too strict about `key`, and an OIDC token value fails it. There
were two independent problems:

1. `\w+` rejects the `.`, `-`, `_` and `=` padding a JWT contains.
2. Even had the regex passed, `parse` split each entry with
`hostKey.split('=')` and took `pair[1]`, so a key containing `=` would
have been **silently truncated** — a worse failure than rejection, since
the mangled key would then be sent to the server.

## Change

Drop `accessKeyRegexp` and the separate `isValid` gate; validate
structurally in `parse` instead, asserting only what is needed to split
the value:

- split on `;`, then split each entry on its **first** `=` only
- hostname: non-empty and free of whitespace (it cannot contain `=` or
`;` by construction)
- key: non-empty and free of whitespace — nothing else is assumed about
its shape

Two small intentional behaviour deltas beyond the fix:

- The whole value is now trimmed, so a trailing newline on a secret no
longer rejects the key. Internal whitespace still rejects.
- Empty entries (`host=key;`, `;host=key`) still reject, as before.

## Testing

Added cases to `short-lived-token.test.ts`: a JWT-shaped key value,
first-separator-only splitting (`host1=a=b==` → key `a=b==`),
surrounding-whitespace tolerance, a `raw()` round-trip preserving `==`
padding, and a table of nine rejection cases. 32 tests pass; prettier,
eslint and `./build` are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 20:59:47 +00:00

248 lines
9.8 KiB
TypeScript

import * as core from '@actions/core'
import * as httpm from '@actions/http-client'
import {DevelocityConfig} from '../configuration'
import {recordDeprecation} from '../deprecation-collector'
/**
* Exchange the configured Develocity access key(s) for short-lived tokens, export them as the access
* key env vars, and return the short-lived token matching the configured Develocity server URL (for
* use as the `develocityAccessToken` cache option). Returns `undefined` when there is no access key,
* token fetching fails, or no token matches the configured server.
*/
export async function setupToken(config: DevelocityConfig): Promise<string | undefined> {
const develocityAccessKey = config.getDevelocityAccessKey()
if (!develocityAccessKey) {
return undefined
}
try {
core.debug('Fetching short-lived token...')
const tokens = await getToken(
develocityAccessKey,
config.getDevelocityAllowUntrustedServer(),
config.getDevelocityTokenExpiry()
)
if (tokens != null && !tokens.isEmpty()) {
core.debug(`Got token(s), setting the access key env vars`)
const token = tokens.raw()
core.setSecret(token)
exportAccessKeyEnvVars(token)
for (const k of tokens.keys) {
core.setSecret(k.key)
}
const serverUrl = config.getDevelocityUrl()
return serverUrl ? resolveTokenForServer(tokens, serverUrl) : undefined
}
handleMissingAccessToken()
} catch (e) {
handleMissingAccessToken()
core.warning(`Failed to fetch short-lived token, reason: ${e}`)
}
return undefined
}
function exportAccessKeyEnvVars(value: string): void {
;[DevelocityConfig.DevelocityAccessKeyEnvVar, DevelocityConfig.GradleEnterpriseAccessKeyEnvVar].forEach(key =>
core.exportVariable(key, value)
)
}
function handleMissingAccessToken(): void {
core.warning(`Failed to fetch short-lived token for Develocity`)
if (process.env[DevelocityConfig.GradleEnterpriseAccessKeyEnvVar]) {
// We do not clear the GRADLE_ENTERPRISE_ACCESS_KEY env var in v3, to let the users upgrade to DV 2024.1
recordDeprecation(`The ${DevelocityConfig.GradleEnterpriseAccessKeyEnvVar} env var is deprecated`)
}
if (process.env[DevelocityConfig.DevelocityAccessKeyEnvVar]) {
core.warning(
`The ${DevelocityConfig.DevelocityAccessKeyEnvVar} env var should be mapped to a short-lived token`
)
}
}
export async function getToken(
accessKey: string,
allowUntrustedServer: undefined | boolean,
expiry: string
): Promise<DevelocityAccessCredentials | null> {
const empty: Promise<DevelocityAccessCredentials | null> = new Promise(r => r(null))
const develocityAccessKey = DevelocityAccessCredentials.parse(accessKey)
const shortLivedTokenClient = new ShortLivedTokenClient(allowUntrustedServer)
if (develocityAccessKey == null) {
return empty
}
const tokens = new Array<HostnameAccessKey>()
for (const k of develocityAccessKey.keys) {
try {
core.info(`Requesting short-lived Develocity access token for ${k.hostname}`)
const token = await shortLivedTokenClient.fetchToken(`https://${k.hostname}`, k, expiry)
tokens.push(token)
} catch (e) {
// Ignore failure to obtain token
core.info(`Failed to obtain short-lived Develocity access token for ${k.hostname}: ${e}`)
}
}
if (tokens.length > 0) {
return DevelocityAccessCredentials.of(tokens)
}
return empty
}
class ShortLivedTokenClient {
httpc: httpm.HttpClient
maxRetries = 3
retryInterval = 1000
constructor(develocityAllowUntrustedServer: boolean | undefined) {
this.httpc = new httpm.HttpClient('gradle/actions/setup-gradle', undefined, {
ignoreSslError: develocityAllowUntrustedServer
})
}
async fetchToken(serverUrl: string, accessKey: HostnameAccessKey, expiry: string): Promise<HostnameAccessKey> {
const queryParams = expiry ? `?expiresInHours=${expiry}` : ''
const sanitizedServerUrl = !serverUrl.endsWith('/') ? `${serverUrl}/` : serverUrl
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessKey.key}`
}
let attempts = 0
while (attempts < this.maxRetries) {
try {
const requestUrl = `${sanitizedServerUrl}api/auth/token${queryParams}`
core.debug(`Attempt ${attempts} to fetch short lived token at ${requestUrl}`)
const response = await this.httpc.post(requestUrl, '', headers)
if (response.message.statusCode === 200) {
const text = await response.readBody()
return new Promise<HostnameAccessKey>(resolve => resolve({hostname: accessKey.hostname, key: text}))
}
// This should be only 404
attempts++
if (attempts === this.maxRetries) {
return new Promise((_resolve, reject) =>
reject(
new Error(
`Develocity short lived token request failed ${serverUrl} with status code ${response.message.statusCode}`
)
)
)
}
} catch (error) {
attempts++
if (attempts === this.maxRetries) {
return new Promise((_resolve, reject) => reject(error))
}
}
await new Promise(resolve => setTimeout(resolve, this.retryInterval))
}
return new Promise((_resolve, reject) => reject(new Error('Illegal state')))
}
}
type HostnameAccessKey = {
hostname: string
key: string
}
export class DevelocityAccessCredentials {
readonly keys: HostnameAccessKey[]
private constructor(allKeys: HostnameAccessKey[]) {
this.keys = allKeys
}
static of(allKeys: HostnameAccessKey[]): DevelocityAccessCredentials {
return new DevelocityAccessCredentials(allKeys)
}
private static readonly keyDelimiter = ';'
private static readonly hostDelimiter = '='
private static readonly whitespace = /\s/
/**
* Parse a `host=key[;host=key]*` access key value.
*
* Only the structure needed to split the value is validated: entries are separated by `;`, and
* each entry is a hostname followed by `=` and a key, where the hostname contains no `=`, `;` or
* whitespace, and the key is non-empty and contains no `;` or whitespace. Nothing else is
* assumed about the key: it may be an OIDC token containing `.`, `-`, `_` and `=` padding, so
* each entry is split on its _first_ `=` only.
*
* Returns `null` if the value doesn't match, emitting a warning that describes what is wrong.
*/
static parse(rawKey: string): DevelocityAccessCredentials | null {
const trimmedKey = rawKey.trim()
if (!trimmedKey) {
return null
}
const keys = new Array<HostnameAccessKey>()
const entries = trimmedKey.split(this.keyDelimiter)
for (const [index, entry] of entries.entries()) {
const separatorIndex = entry.indexOf(this.hostDelimiter)
if (separatorIndex === -1) {
return this.warnBadlyFormed(index, entries.length, `no '${this.hostDelimiter}' separator`)
}
const hostname = entry.substring(0, separatorIndex)
const key = entry.substring(separatorIndex + 1)
if (!hostname) {
return this.warnBadlyFormed(index, entries.length, 'empty server name')
}
if (!key) {
return this.warnBadlyFormed(index, entries.length, 'empty key')
}
if (this.whitespace.test(hostname)) {
return this.warnBadlyFormed(index, entries.length, 'whitespace in the server name')
}
if (this.whitespace.test(key)) {
return this.warnBadlyFormed(index, entries.length, 'whitespace in the key')
}
keys.push({hostname, key})
}
return new DevelocityAccessCredentials(keys)
}
/**
* Warn that an access key value is badly formed and cannot be parsed. Reports only the position
* of the offending entry and the reason: the value is a secret, and is not yet registered for
* masking at this point, so no part of it is ever included in the message.
*/
private static warnBadlyFormed(index: number, entryCount: number, reason: string): null {
const location = entryCount > 1 ? `entry ${index + 1} of ${entryCount}` : 'the value'
core.warning(
`Ignoring badly formed Develocity access key: ${reason} in ${location}. ` +
`The expected format is 'server${this.hostDelimiter}key` +
`[${this.keyDelimiter}server${this.hostDelimiter}key]*'.`
)
return null
}
isEmpty(): boolean {
return this.keys.length === 0
}
raw(): string {
return this.keys
.map(k => `${k.hostname}${DevelocityAccessCredentials.hostDelimiter}${k.key}`)
.join(DevelocityAccessCredentials.keyDelimiter)
}
}
/**
* Resolve the token whose hostname matches a given Develocity server URL. Returns `undefined`
* (fail-closed) when the server URL is empty or no token matches the server's host.
*/
export function resolveTokenForServer(tokens: DevelocityAccessCredentials, serverUrl: string): string | undefined {
if (!serverUrl) {
return undefined
}
let host: string
try {
host = new URL(serverUrl).hostname
} catch {
host = serverUrl // tolerate a bare hostname (no scheme)
}
return tokens.keys.find(k => k.hostname === host)?.key
}