Skip to content

Commit d6e095a

Browse files
authored
Merge pull request #29 from Code-Hex/support/x509
supported x509 for session cookie
2 parents c17ea54 + 4380253 commit d6e095a

10 files changed

+310
-20
lines changed

example/index.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ app.get('/admin/login', async c => {
7272
<script type="module">
7373
// See https://firebase.google.com/docs/auth/admin/manage-cookies
7474
//
75-
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-app.js';
75+
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.0.2/firebase-app.js';
7676
import $ from 'https://cdn.skypack.dev/jquery';
7777
// Add Firebase products that you want to use
7878
import {
@@ -83,7 +83,7 @@ app.get('/admin/login', async c => {
8383
signOut,
8484
setPersistence,
8585
inMemoryPersistence,
86-
} from 'https://www.gstatic.com/firebasejs/10.5.0/firebase-auth.js';
86+
} from 'https://www.gstatic.com/firebasejs/11.0.2/firebase-auth.js';
8787
const app = initializeApp({
8888
apiKey: 'test1234',
8989
authDomain: 'test',
@@ -151,7 +151,7 @@ app.post('/admin/login_session', async c => {
151151
return c.json({ message: 'invalid idToken' }, 400);
152152
}
153153
// Set session expiration to 5 days.
154-
const expiresIn = 60 * 60 * 24 * 5 * 1000;
154+
const expiresIn = 60 * 60 * 24 * 5;
155155
// Create the session cookie. This will also verify the ID token in the process.
156156
// The session cookie will have the same claims as the ID token.
157157
// To only allow session cookie setting on recent sign-in, auth_time in ID token

example/wrangler.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name = "firebase-auth-example"
2-
compatibility_date = "2023-12-01"
2+
compatibility_date = "2024-12-22"
33
workers_dev = true
44
main = "index.ts"
55

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

2121
# See: https://cloud.google.com/iam/docs/keys-create-delete
22-
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]\"}"
22+
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]"}'
2323

2424
# Setup user account in Emulator UI
2525
EMAIL_ADDRESS = "[email protected]"

src/jwk-fetcher.ts

+35-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { JsonWebKeyWithKid } from './jwt-decoder';
21
import type { KeyStorer } from './key-store';
32
import { isNonNullObject, isObject, isURL } from './validator';
3+
import { jwkFromX509 } from './x509';
44

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

27+
export const isX509Certificates = (value: any): value is Record<string, string> => {
28+
if (!isNonNullObject(value)) {
29+
return false;
30+
}
31+
const values = Object.values(value);
32+
if (values.length === 0) {
33+
return false;
34+
}
35+
for (const v of values) {
36+
if (typeof v !== 'string' || v === '') {
37+
return false;
38+
}
39+
}
40+
return true;
41+
};
42+
2743
/**
2844
* Class to fetch public keys from a client certificates URL.
2945
*/
@@ -54,20 +70,32 @@ export class UrlKeyFetcher implements KeyFetcher {
5470
throw new Error(errorMessage + text);
5571
}
5672

57-
const publicKeys = await resp.json();
58-
if (!isJWKMetadata(publicKeys)) {
59-
throw new Error(`The public keys are not an object or null: "${publicKeys}`);
60-
}
73+
const json = await resp.json();
74+
const publicKeys = await this.retrievePublicKeys(json);
6175

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

6478
// store the public keys cache in the KV store.
6579
const maxAge = parseMaxAge(cacheControlHeader);
6680
if (!isNaN(maxAge) && maxAge > 0) {
67-
await this.keyStorer.put(JSON.stringify(publicKeys.keys), maxAge);
81+
await this.keyStorer.put(JSON.stringify(publicKeys), maxAge);
6882
}
6983

70-
return publicKeys.keys;
84+
return publicKeys;
85+
}
86+
87+
private async retrievePublicKeys(json: unknown): Promise<Array<JsonWebKeyWithKid>> {
88+
if (isX509Certificates(json)) {
89+
const jwks: JsonWebKeyWithKid[] = [];
90+
for (const [kid, x509] of Object.entries(json)) {
91+
jwks.push(await jwkFromX509(kid, x509));
92+
}
93+
return jwks;
94+
}
95+
if (!isJWKMetadata(json)) {
96+
throw new Error(`The public keys are not an object or null: "${json}`);
97+
}
98+
return json.keys;
7199
}
72100
}
73101

src/jws-verifier.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { JwtError, JwtErrorCode } from './errors';
22
import type { KeyFetcher } from './jwk-fetcher';
33
import { HTTPFetcher, UrlKeyFetcher } from './jwk-fetcher';
4-
import type { JsonWebKeyWithKid, RS256Token } from './jwt-decoder';
4+
import type { RS256Token } from './jwt-decoder';
55
import type { KeyStorer } from './key-store';
66
import { isNonNullObject } from './validator';
77

src/jwt-decoder.ts

-4
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ export interface TokenDecoder {
77
decode(token: string): Promise<RS256Token>;
88
}
99

10-
export interface JsonWebKeyWithKid extends JsonWebKey {
11-
kid: string;
12-
}
13-
1410
export type DecodedHeader = { kid: string; alg: 'RS256' } & Record<string, any>;
1511

1612
export type DecodedPayload = {

src/token-verifier.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ export function baseCreateIdTokenVerifier(
432432
}
433433

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

437437
/**
438438
* User facing token information related to the Firebase session cookie.

src/x509.ts

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { decodeBase64 } from './base64';
2+
3+
/**
4+
* Parses a sequence of ASN.1 elements from a given Uint8Array.
5+
* Internally, this function repeatedly calls `parseElement` on
6+
* the subarray until the entire sequence is consumed, returning
7+
* an array of parsed elements.
8+
*/
9+
function getElement(seq: Uint8Array) {
10+
const result = [];
11+
let next = 0;
12+
13+
while (next < seq.length) {
14+
// Parse one ASN.1 element from the remaining subarray
15+
const nextPart = parseElement(seq.subarray(next));
16+
result.push(nextPart);
17+
// Advance the pointer by the element's total byte length
18+
next += nextPart.byteLength;
19+
}
20+
return result;
21+
}
22+
23+
/**
24+
* Parses a single ASN.1 element (in DER encoding) from the given byte array.
25+
*
26+
* Each element consists of:
27+
* 1) Tag (possibly multiple bytes if 0x1f is encountered)
28+
* 2) Length (short form or long form, possibly indefinite)
29+
* 3) Contents (the data payload)
30+
*
31+
* Returns an object containing:
32+
* - byteLength: total size (in bytes) of this element (including tag & length)
33+
* - contents: Uint8Array of just the element's contents
34+
* - raw: Uint8Array of the entire element (tag + length + contents)
35+
*/
36+
function parseElement(bytes: Uint8Array) {
37+
let position = 0;
38+
39+
// --- Parse Tag ---
40+
// The tag is in the lower 5 bits (0x1f). If it's 0x1f, it indicates a multi-byte tag.
41+
let tag = bytes[0] & 0x1f;
42+
position++;
43+
if (tag === 0x1f) {
44+
tag = 0;
45+
// Continue reading the tag bytes while each byte >= 0x80
46+
while (bytes[position] >= 0x80) {
47+
tag = tag * 128 + bytes[position] - 0x80;
48+
position++;
49+
}
50+
tag = tag * 128 + bytes[position] - 0x80;
51+
position++;
52+
}
53+
54+
// --- Parse Length ---
55+
let length = 0;
56+
// Short-form length: if less than 0x80, it's the length itself
57+
if (bytes[position] < 0x80) {
58+
length = bytes[position];
59+
position++;
60+
} else if (length === 0x80) {
61+
// Indefinite length form: scan until 0x00 0x00
62+
length = 0;
63+
while (bytes[position + length] !== 0 || bytes[position + length + 1] !== 0) {
64+
if (length > bytes.byteLength) {
65+
throw new TypeError('invalid indefinite form length');
66+
}
67+
length++;
68+
}
69+
const byteLength = position + length + 2;
70+
return {
71+
byteLength,
72+
contents: bytes.subarray(position, position + length),
73+
raw: bytes.subarray(0, byteLength),
74+
};
75+
} else {
76+
// Long-form length: the lower 7 bits of this byte indicates how many bytes follow for length
77+
const numberOfDigits = bytes[position] & 0x7f;
78+
position++;
79+
length = 0;
80+
// Accumulate the length from these "numberOfDigits" bytes
81+
for (let i = 0; i < numberOfDigits; i++) {
82+
length = length * 256 + bytes[position];
83+
position++;
84+
}
85+
}
86+
87+
// The total byte length of this element (tag + length + contents)
88+
const byteLength = position + length;
89+
return {
90+
byteLength,
91+
contents: bytes.subarray(position, byteLength),
92+
raw: bytes.subarray(0, byteLength),
93+
};
94+
}
95+
96+
/**
97+
* Extracts the SubjectPublicKeyInfo (SPKI) portion from a DER-encoded X.509 certificate.
98+
*
99+
* Steps:
100+
* 1) Parse the entire certificate as an ASN.1 SEQUENCE.
101+
* 2) Retrieve the TBS (To-Be-Signed) Certificate, which is the first element.
102+
* 3) Parse the TBS Certificate to get its internal fields (version, serial, issuer, etc.).
103+
* 4) Depending on whether the version field is present (tag = 0xa0), the SPKI is either
104+
* at index 6 or 5 (skipping version if absent).
105+
* 5) Finally, encode the raw SPKI bytes in CryptoKey and return.
106+
*/
107+
async function spkiFromX509(buf: Uint8Array): Promise<CryptoKey> {
108+
// Parse the top-level ASN.1 structure, then get the top-level contents
109+
// which typically contain [ TBS Certificate, signatureAlgorithm, signature ].
110+
// Retrieve TBS Certificate as [0], then parse TBS Certificate further.
111+
const tbsCertificate = getElement(getElement(parseElement(buf).contents)[0].contents);
112+
113+
// In the TBS Certificate, check whether the first element (index 0) is a version field (tag=0xa0).
114+
// If it is, the SubjectPublicKeyInfo is the 7th element (index 6).
115+
// Otherwise, it is the 6th element (index 5).
116+
const spki = tbsCertificate[tbsCertificate[0].raw[0] === 0xa0 ? 6 : 5].raw;
117+
return await crypto.subtle.importKey(
118+
'spki',
119+
spki,
120+
{
121+
name: 'RSASSA-PKCS1-v1_5',
122+
hash: 'SHA-256',
123+
},
124+
true,
125+
['verify']
126+
);
127+
}
128+
129+
export async function jwkFromX509(kid: string, x509: string): Promise<JsonWebKeyWithKid> {
130+
const pem = x509.replace(/(?:-----(?:BEGIN|END) CERTIFICATE-----|\s)/g, '');
131+
const raw = decodeBase64(pem);
132+
const spki = await spkiFromX509(raw);
133+
const { kty, alg, n, e } = await crypto.subtle.exportKey('jwk', spki);
134+
return {
135+
kid,
136+
use: 'sig',
137+
kty,
138+
alg,
139+
n,
140+
e,
141+
};
142+
}

tests/jwk-fetcher.test.ts

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Miniflare } from 'miniflare';
22
import { describe, it, expect, vi } from 'vitest';
33
import type { Fetcher } from '../src/jwk-fetcher';
4-
import { isJWKMetadata, parseMaxAge, UrlKeyFetcher } from '../src/jwk-fetcher';
4+
import { isJWKMetadata, isX509Certificates, parseMaxAge, UrlKeyFetcher } from '../src/jwk-fetcher';
55
import { WorkersKVStore } from '../src/key-store';
66

77
class HTTPMockFetcher implements Fetcher {
@@ -205,3 +205,52 @@ describe('isJWKMetadata', () => {
205205
expect(isJWKMetadata({ keys: [{ kid: 'string' }, {}] })).toBe(false);
206206
});
207207
});
208+
209+
describe('isX509Certificates', () => {
210+
it('should return true for valid X509 certificates', () => {
211+
const validX509 = {
212+
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
213+
cert2: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7',
214+
};
215+
expect(isX509Certificates(validX509)).toBe(true);
216+
});
217+
218+
it('should return false for null', () => {
219+
expect(isX509Certificates(null)).toBe(false);
220+
});
221+
222+
it('should return false for undefined', () => {
223+
expect(isX509Certificates(undefined)).toBe(false);
224+
});
225+
226+
it('should return false for non-object', () => {
227+
expect(isX509Certificates('string')).toBe(false);
228+
expect(isX509Certificates(123)).toBe(false);
229+
expect(isX509Certificates(true)).toBe(false);
230+
});
231+
232+
it('should return false for object with non-string values', () => {
233+
const invalidX509 = {
234+
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
235+
cert2: 123,
236+
};
237+
expect(isX509Certificates(invalidX509)).toBe(false);
238+
});
239+
240+
it('should return false for object with empty values', () => {
241+
const invalidX509 = {
242+
cert1: '',
243+
cert2: '',
244+
};
245+
expect(isX509Certificates(invalidX509)).toBe(false);
246+
});
247+
248+
it('should return false for object with mixed valid and invalid values', () => {
249+
const invalidX509 = {
250+
cert1: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz6',
251+
cert2: 123,
252+
cert3: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7',
253+
};
254+
expect(isX509Certificates(invalidX509)).toBe(false);
255+
});
256+
});

tests/jwk-utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { encodeBase64Url, encodeObjectBase64Url } from '../src/base64';
22
import type { KeyFetcher } from '../src/jwk-fetcher';
33
import { rs256alg } from '../src/jws-verifier';
4-
import type { DecodedHeader, DecodedPayload, JsonWebKeyWithKid } from '../src/jwt-decoder';
4+
import type { DecodedHeader, DecodedPayload } from '../src/jwt-decoder';
55
import { utf8Encoder } from '../src/utf8';
6+
import type { JsonWebKeyWithKid } from '@cloudflare/workers-types';
67

78
export class TestingKeyFetcher implements KeyFetcher {
89
constructor(

0 commit comments

Comments
 (0)