Skip to content

Commit

Permalink
refactor: Move Google Cloud specific stuff to gcp namespace (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
koistya authored May 31, 2022
1 parent a2792c0 commit a0c2719
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 270 deletions.
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ a [service account key](https://cloud.google.com/iam/docs/creating-managing-serv
(JSON), in [Cloudflare Workers](https://workers.cloudflare.com/) environment:

```ts
import { getAuthToken } from "web-auth-library";
import { getAuthToken } from "web-auth-library/gcp";

export default {
async fetch(req, env) {
Expand All @@ -47,8 +47,30 @@ export default {
```

Where `env.GOOGLE_CLOUD_CREDENTIALS` is an environment variable / secret
containing a base64-encoded [service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
obtained from the [Google Cloud Platform](https://cloud.google.com/).
containing a [service account key](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
(JSON) obtained from the [Google Cloud Platform](https://cloud.google.com/).

```ts
import { getAuthToken, importKey, sign } from "web-auth-library/gcp";

// Get an ID token for the target resource (audience)
const token = await getAuthToken({
credentials: env.GOOGLE_CLOUD_CREDENTIALS,
audience: "https://example.com",
});
// => {
// idToken: "eyJhbGciOiJSUzI1NiIsImtpZ...",
// audience: "https://example.com",
// expires: 1653855236,
// }

// Convert GCP service account key into `CryptoKey` object
const credentials = JSON.parse(env.GOOGLE_CLOUD_CREDENTIALS);
const signingKey = await importKey(credentials.private_key, ["sign"]);

// Generate a digital signature
const signature = await sign(signingKey, "xxx");
```

## Backers 💰

Expand Down
131 changes: 131 additions & 0 deletions gcp/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* SPDX-FileCopyrightText: 2020-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import QuickLRU from "quick-lru";
import { Credentials, getCredentials } from "./credentials.js";
import { importKey, sign } from "./crypto.js";

type AuthTokenOptions<T extends "accessToken" | "idToken"> =
T extends "accessToken"
? {
credentials: Credentials | string;
scope: string[] | string;
audience?: never;
}
: {
credentials: Credentials | string;
audience: string;
scope?: never;
};

type AccessToken = {
accessToken: string;
type: string;
scope: string;
expires: number;
};

type IdToken = {
idToken: string;
audience: string;
expires: number;
};

type AuthError = {
error: string;
error_description: string;
};

const cache = new QuickLRU<symbol, any>({
maxSize: 100,
maxAge: 3600000 - 10000,
});

/**
* Retrieves an authentication token from OAuth 2.0 authorization server.
*
* @example
* const token = await getAuthToken({
* credentials: env.GOOGLE_CLOUD_CREDENTIALS,
* scope: "https://www.googleapis.com/auth/cloud-platform"
* );
* const headers = { Authorization: `Bearer ${token.accessToken}` };
* const res = await fetch(url, { headers });
*/
async function getAuthToken<
T extends AuthTokenOptions<"accessToken"> | AuthTokenOptions<"idToken">
>(
options: T
): Promise<T extends AuthTokenOptions<"accessToken"> ? AccessToken : IdToken> {
// Normalize input arguments
const credentials = getCredentials(options.credentials);
const scope =
"scope" in options && Array.isArray(options.scope)
? options.scope.sort().join(" ")
: (options.scope as string | undefined) ?? options.audience;

// Attempt to retrieve the token from the cache
const keyId = credentials?.private_key_id ?? credentials.client_email;
const cacheKey = Symbol.for(`${keyId}:${scope}`);
let token = cache.get(cacheKey);

if (!token) {
token = fetchAuthToken(credentials, scope);
cache.set(cacheKey, token);
}

return token;
}

export async function fetchAuthToken(
credentials: Credentials,
scope: string | undefined
): Promise<AccessToken | IdToken> {
// JWT token header: {"alg":"RS256","typ":"JWT"}
const header = `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9`;

// JWT token attributes
const iss = credentials.client_email;
const aud = credentials.token_uri;
const iat = Math.floor(Date.now() / 1000);
const exp = iat + 3600; // 1 hour max

// JWT token payload
const payload = self
.btoa(JSON.stringify({ iss, aud, scope, exp, iat }))
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");

// JWT token signature
const signingKey = await importKey(credentials.private_key, ["sign"]);
const signature = await sign(signingKey, `${header}.${payload}`);

// OAuth 2.0 authorization request
const body = new FormData();
body.append("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
body.append("assertion", `${header}.${payload}.${signature}`);
const res = await fetch(credentials.token_uri, { method: "POST", body });

if (!res.ok) {
const data = await res.json<AuthError>();
throw new Error(data.error_description ?? data.error);
}

/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const data = await res.json<any>();
return data.access_token
? ({
accessToken: data.access_token.replace(/\.+$/, ""),
type: data.token_type,
scope,
expires: exp,
} as AccessToken)
: ({
idToken: data.id_token?.replace(/\.+$/, ""),
audience: scope,
expires: exp,
} as IdToken);
}

export { type AccessToken, type IdToken, getAuthToken };
39 changes: 39 additions & 0 deletions gcp/credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* SPDX-FileCopyrightText: 2022-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import QuickLRU from "quick-lru";

const cache = new QuickLRU<symbol, Credentials>({ maxSize: 100 });

/**
* Service account key for Google Cloud Platform (GCP)
* https://cloud.google.com/iam/docs/creating-managing-service-account-keys
*/
export type Credentials = {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_id: string;
client_email: string;
auth_uri: string;
token_uri: string;
auth_provider_x509_cert_url: string;
client_x509_cert_url: string;
};

export function getCredentials(value: Credentials | string): Credentials {
if (typeof value === "string") {
const cacheKey = Symbol.for(value);
let credentials = cache.get(cacheKey);

if (!credentials) {
credentials = JSON.parse(value) as Credentials;
cache.set(cacheKey, credentials);
}

return credentials;
}

return value;
}
53 changes: 53 additions & 0 deletions gcp/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* SPDX-FileCopyrightText: 2022-present Kriasoft */
/* SPDX-License-Identifier: MIT */

import { base64, base64url } from "rfc4648";

type KeyUsage =
| "encrypt"
| "decrypt"
| "sign"
| "verify"
| "deriveKey"
| "deriveBits"
| "wrapKey"
| "unwrapKey";

/**
* Returns a `CryptoKey` object that you can use in the `Web Crypto API`.
* https://developer.mozilla.org/docs/Web/API/SubtleCrypto
*
* @example
* const signingKey = await importKey(
* env.GOOGLE_CLOUD_CREDENTIALS.private_key,
* ["sign"],
* );
*/
function importKey(keyData: string, keyUsages: KeyUsage[]): Promise<CryptoKey> {
return crypto.subtle.importKey(
"pkcs8",
base64.parse(
keyData
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replace(/\n/g, "")
),
{
name: "RSASSA-PKCS1-V1_5",
hash: { name: "SHA-256" },
},
false,
keyUsages
);
}

/**
* Generates a digital signature.
*/
async function sign(key: CryptoKey, data: string): Promise<string> {
const input = new TextEncoder().encode(data);
const output = await self.crypto.subtle.sign(key.algorithm, key, input);
return base64url.stringify(new Uint8Array(output), { pad: false });
}

export { sign, importKey, KeyUsage };
6 changes: 6 additions & 0 deletions gcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* SPDX-FileCopyrightText: 2022-present Kriasoft */
/* SPDX-License-Identifier: MIT */

export { AccessToken, getAuthToken, IdToken } from "./auth.js";
export { Credentials, getCredentials } from "./credentials.js";
export { importKey, KeyUsage, sign } from "./crypto.js";
25 changes: 7 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web-auth-library",
"version": "0.2.0",
"version": "0.3.0",
"packageManager": "[email protected]",
"description": "Authentication library for the browser environment using Web Crypto API",
"license": "MIT",
Expand Down Expand Up @@ -35,26 +35,15 @@
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./auth": {
"types": "./dist/auth.d.ts",
"import": "./dist/auth.js"
},
"./credentials": {
"types": "./dist/credentials.d.ts",
"import": "./dist/credentials.js"
},
"./crypto": {
"types": "./dist/crypto.d.ts",
"import": "./dist/crypto.js"
}
"./gcp": "./dist/gcp/index.js",
"./gcp/auth": "./dist/gcp/auth.js",
"./gcp/credentials": "./dist/gcp/credentials.js",
"./gcp/crypto": "./dist/gcp/crypto.js",
"./package.json": "./package.json"
},
"dependencies": {
"quick-lru": "^6.1.1",
"rfc4648": "^1.5.1"
"rfc4648": "^1.5.2"
},
"devDependencies": {
"@cloudflare/workers-types": "^3.11.0",
Expand Down
Loading

0 comments on commit a0c2719

Please sign in to comment.