diff --git a/src/config.ts b/src/config.ts index 4fa5f1b..012f0d7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,9 +34,21 @@ export type CircleOIDCPlatform = OIDCPlatform< } >; +export type GitHubActionsOIDCPlatform = OIDCPlatform< + 'github', + { + organizationId: string; + }, + { + repositoryIds: string[]; + environments: string[]; + } +>; + export type InvalidOIDCPlatform = OIDCPlatform<'invalid', object, object>; export type OIDCSecretExchangeConfig = ( | OIDCSecretExchangeConfigItem + | OIDCSecretExchangeConfigItem | OIDCSecretExchangeConfigItem )[]; diff --git a/src/index.ts b/src/index.ts index 66203df..2870cff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,15 @@ export const configureAndListen = async ( ); break; } + case 'github': { + filteredSecretProviders = await getProvidersForConfig( + req.log, + configuration, + perPlatformHandlers.github, + token, + ); + break; + } case 'invalid': { filteredSecretProviders = await getProvidersForConfig( req.log, diff --git a/src/oidc/handlers.ts b/src/oidc/handlers.ts index 98b1750..cdc4efc 100644 --- a/src/oidc/handlers.ts +++ b/src/oidc/handlers.ts @@ -1,12 +1,13 @@ import { FastifyBaseLogger } from 'fastify'; import { CircleOIDCPlatform, + GitHubActionsOIDCPlatform, InvalidOIDCPlatform, OIDCSecretExchangeConfig, OIDCSecretExchangeConfigItem, OIDCSecretExchangeConfiguration, } from '../config'; -import { BaseClaims, CircleCIOIDCClaims } from '../type'; +import { BaseClaims, CircleCIOIDCClaims, GitHubActionsOIDCClaims } from '../type'; import { getValidatedToken } from './validate-token'; export type SubjectedSecretProvider = { @@ -19,7 +20,7 @@ export type PlatformHandler< TClaims extends BaseClaims, > = { discoveryUrlForToken: (config: TConfig, token: string) => string | null; - validateToken: (token: string, discoveryUrl: string) => Promise; + validateToken: (config: TConfig, token: string, discoveryUrl: string) => Promise; filterSecretProviders: (config: TConfig, claims: TClaims) => Promise; }; @@ -27,11 +28,11 @@ const circleci: PlatformHandler< OIDCSecretExchangeConfigItem, CircleCIOIDCClaims > = { - discoveryUrlForToken: (config, token) => { + discoveryUrlForToken: (config) => { // TODO: Pre-validate the token to claim this audience return `https://oidc.circleci.com/org/${config.organizationId}`; }, - validateToken: async (token, discoveryUrl) => { + validateToken: async (_, token, discoveryUrl) => { return await getValidatedToken(token, discoveryUrl); }, filterSecretProviders: async (config, claims) => { @@ -63,6 +64,53 @@ const circleci: PlatformHandler< }, }; +const github: PlatformHandler< + OIDCSecretExchangeConfigItem, + GitHubActionsOIDCClaims +> = { + discoveryUrlForToken: () => { + return 'https://token.actions.githubusercontent.com'; + }, + validateToken: async (config, token, discoveryUrl) => { + const validated = await getValidatedToken(token, discoveryUrl); + if (validated) { + // If the token was validated but the owner doesn't match the org ID, the token is invalid + if ( + !validated.repository_owner_id || + validated.repository_owner_id !== config.organizationId + ) { + return null; + } + } + return validated; + }, + filterSecretProviders: async (config, claims) => { + const validatedEnvironment = claims.environment; + const repoId = claims.repository_id; + const ownerId = claims.repository_owner_id; + if (config.organizationId !== ownerId) return { subject: claims.sub, providers: [] }; + + const filteredSecretProviders = config.secrets + .filter((provider) => { + if (!provider.filters.repositoryIds.includes(repoId)) return false; + // If the wildcard environment is allowed then we don't need to check the environment filter + if (!provider.filters.environments.includes('*')) { + // If the environment is missing from the claim, don't validate it in case consumers accidentally put + // 'undefined' in the environments array + if ( + !validatedEnvironment || + !provider.filters.environments.includes(validatedEnvironment) + ) + return false; + } + return true; + }) + .map((p) => p.provider); + + return { subject: claims.sub, providers: filteredSecretProviders }; + }, +}; + const invalid: PlatformHandler, BaseClaims> = { discoveryUrlForToken: () => { return null; @@ -77,6 +125,7 @@ const invalid: PlatformHandler export const perPlatformHandlers = { circleci, + github, invalid, }; @@ -93,7 +142,7 @@ export const getProvidersForConfig = async < const discoveryUrl = handler.discoveryUrlForToken(config, token); if (!discoveryUrl) return null; - const claims = await handler.validateToken(token, discoveryUrl); + const claims = await handler.validateToken(config, token, discoveryUrl); if (!claims) return null; logger.info(`Validated incoming OIDC token from: ${claims.sub}`); diff --git a/src/type.ts b/src/type.ts index 72f1ced..9ed3715 100644 --- a/src/type.ts +++ b/src/type.ts @@ -16,3 +16,22 @@ export type CircleCIOIDCClaims = { */ 'oidc.circleci.com/context-ids': string[]; }; + +export type GitHubActionsOIDCClaims = { + /** + * The subject, this identifies the repo + org. It's value is "repo:foo/bar:environment:fee" + */ + sub: string; + /** + * Environment the job is running in, if no environment will be empty + */ + environment: string | null; + /** + * ID of the repo + */ + repository_id: string; + /** + * ID of the org + */ + repository_owner_id: string; +};