mirror of
https://github.com/gradle/actions.git
synced 2026-09-10 19:28:33 +08:00
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>
248 lines
9.8 KiB
TypeScript
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
|
|
}
|