Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

supported x509 for session cookie #29

Merged
merged 2 commits into from
Dec 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions example/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ app.get('/admin/login', async c => {
<script type="module">
// See https://firebase.google.com/docs/auth/admin/manage-cookies
//
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-app.js';
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.0.2/firebase-app.js';
import $ from 'https://cdn.skypack.dev/jquery';
// Add Firebase products that you want to use
import {
Expand All @@ -83,7 +83,7 @@ app.get('/admin/login', async c => {
signOut,
setPersistence,
inMemoryPersistence,
} from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-auth.js';
} from 'https://www.gstatic.com/firebasejs/11.0.2/firebase-auth.js';
const app = initializeApp({
apiKey: 'test1234',
authDomain: 'test',
Expand Down Expand Up @@ -151,7 +151,7 @@ app.post('/admin/login_session', async c => {
return c.json({ message: 'invalid idToken' }, 400);
}
// Set session expiration to 5 days.
const expiresIn = 60 * 60 * 24 * 5 * 1000;
const expiresIn = 60 * 60 * 24 * 5;
// Create the session cookie. This will also verify the ID token in the process.
// The session cookie will have the same claims as the ID token.
// To only allow session cookie setting on recent sign-in, auth_time in ID token
Expand Down
4 changes: 2 additions & 2 deletions example/wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name = "firebase-auth-example"
compatibility_date = "2023-12-01"
compatibility_date = "2024-12-22"
workers_dev = true
main = "index.ts"

Expand All @@ -19,7 +19,7 @@ tsconfig = "./tsconfig.json"
FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"

# See: https://cloud.google.com/iam/docs/keys-create-delete
SERVICE_ACCOUNT_JSON = "{\"type\":\"service_account\",\"project_id\":\"project12345\",\"private_key_id\":\"xxxxxxxxxxxxxxxxx\",\"private_key\":\"-----BEGIN PRIVATE KEY-----XXXXXX-----END PRIVATE KEY-----\n\",\"client_email\":\"[email protected]\",\"client_id\":\"xxxxxx\",\"auth_uri\":\"https://accounts.google.com/o/oauth2/auth\",\"token_uri\":\"https://oauth2.googleapis.com/token\",\"auth_provider_x509_cert_url\":\"https://www.googleapis.com/oauth2/v1/certs\",\"client_x509_cert_url\":\"https://www.googleapis.com/robot/v1/metadata/x509/[email protected]\"}"
SERVICE_ACCOUNT_JSON = '{"type":"service_account","project_id":"project12345","private_key_id":"xxxxxxxxxxxxxxxxx","private_key":"-----BEGIN PRIVATE KEY-----XXXXXX-----END PRIVATE KEY-----\n","client_email":"[email protected]","client_id":"xxxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/[email protected]"}'

# Setup user account in Emulator UI
EMAIL_ADDRESS = "[email protected]"
Expand Down
42 changes: 35 additions & 7 deletions src/jwk-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { JsonWebKeyWithKid } from './jwt-decoder';
import type { KeyStorer } from './key-store';
import { isNonNullObject, isObject, isURL } from './validator';
import { jwkFromX509 } from './x509';

export interface KeyFetcher {
fetchPublicKeys(): Promise<Array<JsonWebKeyWithKid>>;
Expand All @@ -24,6 +24,22 @@ export const isJWKMetadata = (value: any): value is JWKMetadata => {
return keys.length === filtered.length;
};

export const isX509Certificates = (value: any): value is Record<string, string> => {
if (!isNonNullObject(value)) {
return false;
}
const values = Object.values(value);
if (values.length === 0) {
return false;
}
for (const v of values) {
if (typeof v !== 'string' || v === '') {
return false;
}
}
return true;
};

/**
* Class to fetch public keys from a client certificates URL.
*/
Expand Down Expand Up @@ -54,20 +70,32 @@ export class UrlKeyFetcher implements KeyFetcher {
throw new Error(errorMessage + text);
}

const publicKeys = await resp.json();
if (!isJWKMetadata(publicKeys)) {
throw new Error(`The public keys are not an object or null: "${publicKeys}`);
}
const json = await resp.json();
const publicKeys = await this.retrievePublicKeys(json);

const cacheControlHeader = resp.headers.get('cache-control');

// store the public keys cache in the KV store.
const maxAge = parseMaxAge(cacheControlHeader);
if (!isNaN(maxAge) && maxAge > 0) {
await this.keyStorer.put(JSON.stringify(publicKeys.keys), maxAge);
await this.keyStorer.put(JSON.stringify(publicKeys), maxAge);
}

return publicKeys.keys;
return publicKeys;
}

private async retrievePublicKeys(json: unknown): Promise<Array<JsonWebKeyWithKid>> {
if (isX509Certificates(json)) {
const jwks: JsonWebKeyWithKid[] = [];
for (const [kid, x509] of Object.entries(json)) {
jwks.push(await jwkFromX509(kid, x509));
}
return jwks;
}
if (!isJWKMetadata(json)) {
throw new Error(`The public keys are not an object or null: "${json}`);
}
return json.keys;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/jws-verifier.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { JwtError, JwtErrorCode } from './errors';
import type { KeyFetcher } from './jwk-fetcher';
import { HTTPFetcher, UrlKeyFetcher } from './jwk-fetcher';
import type { JsonWebKeyWithKid, RS256Token } from './jwt-decoder';
import type { RS256Token } from './jwt-decoder';
import type { KeyStorer } from './key-store';
import { isNonNullObject } from './validator';

Expand Down
4 changes: 0 additions & 4 deletions src/jwt-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@ export interface TokenDecoder {
decode(token: string): Promise<RS256Token>;
}

export interface JsonWebKeyWithKid extends JsonWebKey {
kid: string;
}

export type DecodedHeader = { kid: string; alg: 'RS256' } & Record<string, any>;

export type DecodedPayload = {
Expand Down
2 changes: 1 addition & 1 deletion src/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export function baseCreateIdTokenVerifier(
}

// URL containing the public keys for Firebase session cookies.
const SESSION_COOKIE_CERT_URL = 'https://identitytoolkit.googleapis.com/v1/sessionCookiePublicKeys';
const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys';

/**
* User facing token information related to the Firebase session cookie.
Expand Down
142 changes: 142 additions & 0 deletions src/x509.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { decodeBase64 } from './base64';

/**
* Parses a sequence of ASN.1 elements from a given Uint8Array.
* Internally, this function repeatedly calls `parseElement` on
* the subarray until the entire sequence is consumed, returning
* an array of parsed elements.
*/
function getElement(seq: Uint8Array) {
const result = [];
let next = 0;

while (next < seq.length) {
// Parse one ASN.1 element from the remaining subarray
const nextPart = parseElement(seq.subarray(next));
result.push(nextPart);
// Advance the pointer by the element's total byte length
next += nextPart.byteLength;
}
return result;
}

/**
* Parses a single ASN.1 element (in DER encoding) from the given byte array.
*
* Each element consists of:
* 1) Tag (possibly multiple bytes if 0x1f is encountered)
* 2) Length (short form or long form, possibly indefinite)
* 3) Contents (the data payload)
*
* Returns an object containing:
* - byteLength: total size (in bytes) of this element (including tag & length)
* - contents: Uint8Array of just the element's contents
* - raw: Uint8Array of the entire element (tag + length + contents)
*/
function parseElement(bytes: Uint8Array) {
let position = 0;

// --- Parse Tag ---
// The tag is in the lower 5 bits (0x1f). If it's 0x1f, it indicates a multi-byte tag.
let tag = bytes[0] & 0x1f;
position++;
if (tag === 0x1f) {
tag = 0;
// Continue reading the tag bytes while each byte >= 0x80
while (bytes[position] >= 0x80) {
tag = tag * 128 + bytes[position] - 0x80;
position++;
}
tag = tag * 128 + bytes[position] - 0x80;
position++;
}

// --- Parse Length ---
let length = 0;
// Short-form length: if less than 0x80, it's the length itself
if (bytes[position] < 0x80) {
length = bytes[position];
position++;
} else if (length === 0x80) {
// Indefinite length form: scan until 0x00 0x00
length = 0;
while (bytes[position + length] !== 0 || bytes[position + length + 1] !== 0) {
if (length > bytes.byteLength) {
throw new TypeError('invalid indefinite form length');
}
length++;
}
const byteLength = position + length + 2;
return {
byteLength,
contents: bytes.subarray(position, position + length),
raw: bytes.subarray(0, byteLength),
};
} else {
// Long-form length: the lower 7 bits of this byte indicates how many bytes follow for length
const numberOfDigits = bytes[position] & 0x7f;
position++;
length = 0;
// Accumulate the length from these "numberOfDigits" bytes
for (let i = 0; i < numberOfDigits; i++) {
length = length * 256 + bytes[position];
position++;
}
}

// The total byte length of this element (tag + length + contents)
const byteLength = position + length;
return {
byteLength,
contents: bytes.subarray(position, byteLength),
raw: bytes.subarray(0, byteLength),
};
}

/**
* Extracts the SubjectPublicKeyInfo (SPKI) portion from a DER-encoded X.509 certificate.
*
* Steps:
* 1) Parse the entire certificate as an ASN.1 SEQUENCE.
* 2) Retrieve the TBS (To-Be-Signed) Certificate, which is the first element.
* 3) Parse the TBS Certificate to get its internal fields (version, serial, issuer, etc.).
* 4) Depending on whether the version field is present (tag = 0xa0), the SPKI is either
* at index 6 or 5 (skipping version if absent).
* 5) Finally, encode the raw SPKI bytes in CryptoKey and return.
*/
async function spkiFromX509(buf: Uint8Array): Promise<CryptoKey> {
// Parse the top-level ASN.1 structure, then get the top-level contents
// which typically contain [ TBS Certificate, signatureAlgorithm, signature ].
// Retrieve TBS Certificate as [0], then parse TBS Certificate further.
const tbsCertificate = getElement(getElement(parseElement(buf).contents)[0].contents);

// In the TBS Certificate, check whether the first element (index 0) is a version field (tag=0xa0).
// If it is, the SubjectPublicKeyInfo is the 7th element (index 6).
// Otherwise, it is the 6th element (index 5).
const spki = tbsCertificate[tbsCertificate[0].raw[0] === 0xa0 ? 6 : 5].raw;
return await crypto.subtle.importKey(
'spki',
spki,
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
true,
['verify']
);
}

export async function jwkFromX509(kid: string, x509: string): Promise<JsonWebKeyWithKid> {
const pem = x509.replace(/(?:-----(?:BEGIN|END) CERTIFICATE-----|\s)/g, '');
const raw = decodeBase64(pem);
const spki = await spkiFromX509(raw);
const { kty, alg, n, e } = await crypto.subtle.exportKey('jwk', spki);
return {
kid,
use: 'sig',
kty,
alg,
n,
e,
};
}
51 changes: 50 additions & 1 deletion tests/jwk-fetcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Miniflare } from 'miniflare';
import { describe, it, expect, vi } from 'vitest';
import type { Fetcher } from '../src/jwk-fetcher';
import { isJWKMetadata, parseMaxAge, UrlKeyFetcher } from '../src/jwk-fetcher';
import { isJWKMetadata, isX509Certificates, parseMaxAge, UrlKeyFetcher } from '../src/jwk-fetcher';
import { WorkersKVStore } from '../src/key-store';

class HTTPMockFetcher implements Fetcher {
Expand Down Expand Up @@ -205,3 +205,52 @@ describe('isJWKMetadata', () => {
expect(isJWKMetadata({ keys: [{ kid: 'string' }, {}] })).toBe(false);
});
});

describe('isX509Certificates', () => {
it('should return true for valid X509 certificates', () => {
const validX509 = {
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
cert2: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7',
};
expect(isX509Certificates(validX509)).toBe(true);
});

it('should return false for null', () => {
expect(isX509Certificates(null)).toBe(false);
});

it('should return false for undefined', () => {
expect(isX509Certificates(undefined)).toBe(false);
});

it('should return false for non-object', () => {
expect(isX509Certificates('string')).toBe(false);
expect(isX509Certificates(123)).toBe(false);
expect(isX509Certificates(true)).toBe(false);
});

it('should return false for object with non-string values', () => {
const invalidX509 = {
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
cert2: 123,
};
expect(isX509Certificates(invalidX509)).toBe(false);
});

it('should return false for object with empty values', () => {
const invalidX509 = {
cert1: '',
cert2: '',
};
expect(isX509Certificates(invalidX509)).toBe(false);
});

it('should return false for object with mixed valid and invalid values', () => {
const invalidX509 = {
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
cert2: 123,
cert3: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7',
};
expect(isX509Certificates(invalidX509)).toBe(false);
});
});
3 changes: 2 additions & 1 deletion tests/jwk-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { encodeBase64Url, encodeObjectBase64Url } from '../src/base64';
import type { KeyFetcher } from '../src/jwk-fetcher';
import { rs256alg } from '../src/jws-verifier';
import type { DecodedHeader, DecodedPayload, JsonWebKeyWithKid } from '../src/jwt-decoder';
import type { DecodedHeader, DecodedPayload } from '../src/jwt-decoder';
import { utf8Encoder } from '../src/utf8';
import type { JsonWebKeyWithKid } from '@cloudflare/workers-types';

export class TestingKeyFetcher implements KeyFetcher {
constructor(
Expand Down
Loading
Loading