Skip to content

Commit

Permalink
feat(iam): openid connect providers
Browse files Browse the repository at this point in the history
Implements `iam.OpenIdConnectProvider` through a custom resource.

See README for details.

Related aws#5388
Related aws#3949
Related aws#6308
  • Loading branch information
Elad Ben-Israel authored and karupanerura committed May 7, 2020
1 parent 0181a43 commit cbcbbaf
Show file tree
Hide file tree
Showing 10 changed files with 944 additions and 1 deletion.
44 changes: 44 additions & 0 deletions packages/@aws-cdk/aws-iam/README.md
Expand Up @@ -238,6 +238,50 @@ const newPolicyDocument = PolicyDocument.fromJson(policyDocument);

```
### OpenID Connect Providers
OIDC identity providers are entities in IAM that describe an external identity
provider (IdP) service that supports the [OpenID Connect] (OIDC) standard, such
as Google or Salesforce. You use an IAM OIDC identity provider when you want to
establish trust between an OIDC-compatible IdP and your AWS account. This is
useful when creating a mobile app or web application that requires access to AWS
resources, but you don't want to create custom sign-in code or manage your own
user identities. For more information about this scenario, see [About Web
Identity Federation] and the relevant documentation in the [Amazon Cognito
Identity Pools Developer Guide].
[OpenID Connect]: http://openid.net/connect
[About Web Identity Federation]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html
[Amazon Cognito Identity Pools Developer Guide]: https://docs.aws.amazon.com/cognito/latest/developerguide/open-id.html
The following examples defines an OpenID Connect provider. Two client IDs
(audiences) are will be able to send authentication requests to
https://openid/connect.
```ts
const provider = new OpenIdConnectProvider(this, 'MyProvider', {
url: 'https://openid/connect',
clients: [ 'myclient1', 'myclient2' ]
});
```
You can specify an optional list of `thumbprints`. If not specified, the
thumbprint of the root certificate authority (CA) will automatically be obtained
from the host as described
[here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html).
Once you define an OpenID connect provider, you can use it with AWS services
that expect an IAM OIDC provider. For example, when you define an [Amazon
Cognito identity
pool](https://docs.aws.amazon.com/cognito/latest/developerguide/open-id.html)
you can reference the provider's ARN as follows:
```ts
new cognito.CfnIdentityPool(this, 'IdentityPool', {
openIdConnectProviderARNs: [ provider.openIdConnectProviderArn ]
});
```
### Features
* Policy name uniqueness is enforced. If two policies by the same name are attached to the same
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-iam/lib/index.ts
Expand Up @@ -10,6 +10,7 @@ export * from './principals';
export * from './identity-base';
export * from './grant';
export * from './unknown-principal';
export * from './oidc-provider';

// AWS::IAM CloudFormation Resources:
export * from './iam.generated';
154 changes: 154 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/oidc-provider.ts
@@ -0,0 +1,154 @@
import { Construct, CustomResource, CustomResourceProvider, CustomResourceProviderRuntime, IResource, Resource, Stack, Token } from '@aws-cdk/core';
import * as path from 'path';

const RESOURCE_TYPE = 'Custom::AWSCDKOpenIdConnectProvider';

/**
* Represents an IAM OpenID Connect provider.
*
* @experimental
*/
export interface IOpenIdConnectProvider extends IResource {
/**
* The Amazon Resource Name (ARN) of the IAM OpenID Connect provider.
*/
readonly openIdConnectProviderArn: string;
}

/**
* Initialization properties for `OpenIdConnectProvider`.
* @experimental
*/
export interface OpenIdConnectProviderProps {
/**
* The URL of the identity provider. The URL must begin with https:// and
* should correspond to the iss claim in the provider's OpenID Connect ID
* tokens. Per the OIDC standard, path components are allowed but query
* parameters are not. Typically the URL consists of only a hostname, like
* https://server.example.org or https://example.com.
*
* You cannot register the same provider multiple times in a single AWS
* account. If you try to submit a URL that has already been used for an
* OpenID Connect provider in the AWS account, you will get an error.
*/
readonly url: string;

/**
* A list of client IDs (also known as audiences). When a mobile or web app
* registers with an OpenID Connect provider, they establish a value that
* identifies the application. (This is the value that's sent as the client_id
* parameter on OAuth requests.)
*
* You can register multiple client IDs with the same provider. For example,
* you might have multiple applications that use the same OIDC provider. You
* cannot register more than 100 client IDs with a single IAM OIDC provider.
*
* Client IDs are up to 255 characters long.
*
* @default - no clients are allowed
*/
readonly clientIds?: string[];

/**
* A list of server certificate thumbprints for the OpenID Connect (OIDC)
* identity provider's server certificates.
*
* Typically this list includes only one entry. However, IAM lets you have up
* to five thumbprints for an OIDC provider. This lets you maintain multiple
* thumbprints if the identity provider is rotating certificates.
*
* The server certificate thumbprint is the hex-encoded SHA-1 hash value of
* the X.509 certificate used by the domain where the OpenID Connect provider
* makes its keys available. It is always a 40-character string.
*
* You must provide at least one thumbprint when creating an IAM OIDC
* provider. For example, assume that the OIDC provider is server.example.com
* and the provider stores its keys at
* https://keys.server.example.com/openid-connect. In that case, the
* thumbprint string would be the hex-encoded SHA-1 hash value of the
* certificate used by https://keys.server.example.com.
*
* @default - If no thumbprints are specified (an empty array or `undefined`),
* the thumbprint of the root certificate authority will be obtained from the
* provider's server as described in https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html
*/
readonly thumbprints?: string[];
}

/**
* IAM OIDC identity providers are entities in IAM that describe an external
* identity provider (IdP) service that supports the OpenID Connect (OIDC)
* standard, such as Google or Salesforce. You use an IAM OIDC identity provider
* when you want to establish trust between an OIDC-compatible IdP and your AWS
* account. This is useful when creating a mobile app or web application that
* requires access to AWS resources, but you don't want to create custom sign-in
* code or manage your own user identities.
*
* @see http://openid.net/connect
* @see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc.html
*
* @experimental
*/
export class OpenIdConnectProvider extends Construct implements IOpenIdConnectProvider {
/**
* Imports an Open ID connect provider from an ARN.
* @param scope The definition scope
* @param id ID of the construct
* @param openIdConnectProviderArn the ARN to import
*/
public static fromOpenIdConnectProviderArn(scope: Construct, id: string, openIdConnectProviderArn: string): IOpenIdConnectProvider {
class Import extends Resource implements IOpenIdConnectProvider {
public readonly openIdConnectProviderArn = openIdConnectProviderArn;
}
return new Import(scope, id);
}

/**
* The Amazon Resource Name (ARN) of the IAM OpenID Connect provider.
*/
public readonly openIdConnectProviderArn: string;

/**
* Defines an OpenID Connect provider.
* @param scope The definition scope
* @param id Construct ID
* @param props Initialization properties
*/
public constructor(scope: Construct, id: string, props: OpenIdConnectProviderProps) {
super(scope, id);

const resource = new CustomResource(this, 'Resource', {
resourceType: RESOURCE_TYPE,
serviceToken: this.getOrCreateProvider(),
properties: {
ClientIDList: props.clientIds,
ThumbprintList: props.thumbprints,
Url: props.url,
},
});

this.openIdConnectProviderArn = Token.asString(resource.ref);
}

public get stack() { return Stack.of(this); }

private getOrCreateProvider() {
return CustomResourceProvider.getOrCreate(this, RESOURCE_TYPE, {
codeDirectory: path.join(__dirname, 'oidc-provider'),
runtime: CustomResourceProviderRuntime.NODEJS_12,
policyStatements: [
{
Effect: 'Allow',
Resource: '*',
Action: [
'iam:CreateOpenIDConnectProvider',
'iam:DeleteOpenIDConnectProvider',
'iam:UpdateOpenIDConnectProviderThumbprint',
'iam:AddClientIDToOpenIDConnectProvider',
'iam:RemoveClientIDFromOpenIDConnectProvider',
],
},
],
});
}
}
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/oidc-provider/diff.ts
@@ -0,0 +1,17 @@
export function arrayDiff(oldValues: string[], newValues: string[]) {
const deletes = new Set(oldValues);
const adds = new Set<string>();

for (const v of new Set(newValues)) {
if (deletes.has(v)) {
deletes.delete(v);
} else {
adds.add(v);
}
}

return {
adds: Array.from(adds),
deletes: Array.from(deletes),
};
}
53 changes: 53 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/oidc-provider/external.ts
@@ -0,0 +1,53 @@
/* istanbul ignore file */

// eslint-disable-next-line import/no-extraneous-dependencies
import * as aws from 'aws-sdk';
import * as tls from 'tls';
import * as url from 'url';

let client: aws.IAM;

function iam() {
if (!client) { client = new aws.IAM(); }
return client;
}

function defaultLogger(fmt: string, ...args: any[]) {
// tslint:disable-next-line: no-console
console.log(fmt, ...args);
}

/**
* Downloads the CA thumbprint from the issuer URL
*/
async function downloadThumbprint(issuerUrl: string) {
external.log(`downloading certificate authority thumbprint for ${issuerUrl}`);
return new Promise<string>((ok, ko) => {
const purl = url.parse(issuerUrl);
const port = purl.port ? parseInt(purl.port, 10) : 443;
if (!purl.host) {
return ko(new Error(`unable to determine host from issuer url ${issuerUrl}`));
}
const socket = tls.connect(port, purl.host, { rejectUnauthorized: false });
socket.once('error', ko);
socket.once('secureConnect', () => {
const cert = socket.getPeerCertificate();
socket.end();
const thumbprint = cert.fingerprint.split(':').join('');
external.log(`certificate authority thumbprint for ${issuerUrl} is ${thumbprint}`);
ok(thumbprint);
});
});
}

// allows unit test to replace with mocks
// tslint:disable:max-line-length
export const external = {
downloadThumbprint,
log: defaultLogger,
createOpenIDConnectProvider: (req: aws.IAM.CreateOpenIDConnectProviderRequest) => iam().createOpenIDConnectProvider(req).promise(),
deleteOpenIDConnectProvider: (req: aws.IAM.DeleteOpenIDConnectProviderRequest) => iam().deleteOpenIDConnectProvider(req).promise(),
updateOpenIDConnectProviderThumbprint: (req: aws.IAM.UpdateOpenIDConnectProviderThumbprintRequest) => iam().updateOpenIDConnectProviderThumbprint(req).promise(),
addClientIDToOpenIDConnectProvider: (req: aws.IAM.AddClientIDToOpenIDConnectProviderRequest) => iam().addClientIDToOpenIDConnectProvider(req).promise(),
removeClientIDFromOpenIDConnectProvider: (req: aws.IAM.RemoveClientIDFromOpenIDConnectProviderRequest) => iam().removeClientIDFromOpenIDConnectProvider(req).promise(),
};
89 changes: 89 additions & 0 deletions packages/@aws-cdk/aws-iam/lib/oidc-provider/index.ts
@@ -0,0 +1,89 @@
import { arrayDiff } from './diff';
import { external } from './external';

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
if (event.RequestType === 'Create') { return onCreate(event); }
if (event.RequestType === 'Update') { return onUpdate(event); }
if (event.RequestType === 'Delete') { return onDelete(event); }
throw new Error('invalid request type');
}

async function onCreate(event: AWSLambda.CloudFormationCustomResourceCreateEvent) {
const issuerUrl = event.ResourceProperties.Url;
const thumbprints: string[] = (event.ResourceProperties.ThumbprintList ?? []).sort(); // keep sorted for UPDATE
const clients: string[] = (event.ResourceProperties.ClientIDList ?? []).sort();

if (thumbprints.length === 0) {
thumbprints.push(await external.downloadThumbprint(issuerUrl));
}

const resp = await external.createOpenIDConnectProvider({
Url: issuerUrl,
ClientIDList: clients,
ThumbprintList: thumbprints,
});

return {
PhysicalResourceId: resp.OpenIDConnectProviderArn,
};
}

async function onUpdate(event: AWSLambda.CloudFormationCustomResourceUpdateEvent) {
const issuerUrl = event.ResourceProperties.Url;
const thumbprints: string[] = (event.ResourceProperties.ThumbprintList ?? []).sort(); // keep sorted for UPDATE
const clients: string[] = (event.ResourceProperties.ClientIDList ?? []).sort();

// determine which update we are talking about.
const oldIssuerUrl = event.OldResourceProperties.Url;

// if this is a URL update, then we basically create a new resource and cfn will delete the old one
// since the physical resource ID will change.
if (oldIssuerUrl !== issuerUrl) {
return onCreate({ ...event, RequestType: 'Create' });
}

const providerArn = event.PhysicalResourceId;

// if thumbprints changed, we can update in-place, but bear in mind that if the new thumbprint list
// is empty, we will grab it from the server like we do in CREATE
const oldThumbprints = (event.OldResourceProperties.ThumbprintList || []).sort();
if (JSON.stringify(oldThumbprints) !== JSON.stringify(thumbprints)) {
const thumbprintList = thumbprints.length > 0 ? thumbprints : [ await external.downloadThumbprint(issuerUrl) ];
external.log('updating thumbprint list from', oldThumbprints, 'to', thumbprints);
await external.updateOpenIDConnectProviderThumbprint({
OpenIDConnectProviderArn: providerArn,
ThumbprintList: thumbprintList,
});

// don't return, we might have more updates...
}

// if client ID list has changed, determine "diff" because the API is add/remove
const oldClients: string[] = (event.OldResourceProperties.ClientIDList || []).sort();
const diff = arrayDiff(oldClients, clients);
external.log(`client ID diff: ${JSON.stringify(diff)}`);

for (const addClient of diff.adds) {
external.log(`adding client id "${addClient}" to provider ${providerArn}`);
await external.addClientIDToOpenIDConnectProvider({
OpenIDConnectProviderArn: providerArn,
ClientID: addClient,
});
}

for (const deleteClient of diff.deletes) {
external.log(`removing client id "${deleteClient}" from provider ${providerArn}`);
await external.removeClientIDFromOpenIDConnectProvider({
OpenIDConnectProviderArn: providerArn,
ClientID: deleteClient,
});
}

return;
}

async function onDelete(deleteEvent: AWSLambda.CloudFormationCustomResourceDeleteEvent) {
await external.deleteOpenIDConnectProvider({
OpenIDConnectProviderArn: deleteEvent.PhysicalResourceId,
});
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-iam/package.json
Expand Up @@ -67,7 +67,8 @@
"cdk-integ-tools": "0.0.0",
"cfn2ts": "0.0.0",
"jest": "^25.5.4",
"pkglint": "0.0.0"
"pkglint": "0.0.0",
"sinon": "^9.0.2"
},
"dependencies": {
"@aws-cdk/core": "0.0.0",
Expand Down

0 comments on commit cbcbbaf

Please sign in to comment.