Skip to content

Commit

Permalink
Add options for using cookies and support for new API keys (#381)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcospassos authored May 6, 2024
1 parent ff3d4a4 commit ed6abf1
Show file tree
Hide file tree
Showing 33 changed files with 3,601 additions and 1,358 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/branch-validations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20

- name: Cache dependencies
id: cache-dependencies
Expand All @@ -40,7 +40,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20

- name: Cache dependencies
id: cache-dependencies
Expand All @@ -62,7 +62,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20

- name: Cache dependencies
id: cache-dependencies
Expand All @@ -84,7 +84,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20

- name: Cache dependencies
id: cache-dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-published-releases.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: 20
registry-url: 'https://registry.npmjs.org'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
2,740 changes: 1,574 additions & 1,166 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@
},
"dependencies": {
"@croct/json": "^2.0.1",
"js-base64": "^3.7.7",
"tslib": "^2.5.0"
},
"devDependencies": {
"@croct/eslint-plugin": "^0.7.0",
"@types/jest": "^29.2.3",
"eslint": "^9.0.0",
"eslint": "^8.57.0",
"fetch-mock": "^9.11.0",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
Expand Down
110 changes: 110 additions & 0 deletions src/apiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as crypto from 'crypto';
import {KeyObject} from 'crypto';

export class ApiKey {
private static readonly IDENTIFIER_PATTERN = /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/i;

private static readonly PRIVATE_KEY_PATTERN = /^[a-f0-9]+$/i;

private readonly identifier: string;

private readonly privateKey?: KeyObject;

private constructor(identifier: string, privateKey?: KeyObject) {
this.identifier = identifier;
this.privateKey = privateKey;
}

public static from(apiKey: string | ApiKey): ApiKey {
if (apiKey instanceof ApiKey) {
return apiKey;
}

return ApiKey.parse(apiKey);
}

public static parse(apiKey: string): ApiKey {
const parts = apiKey.split(':');

if (parts.length > 2) {
throw new Error('Invalid API key format.');
}

return ApiKey.of(parts[0], parts[1]);
}

public static of(identifier: string, privateKey?: string): ApiKey {
if (!ApiKey.IDENTIFIER_PATTERN.test(identifier)) {
throw new Error('The API key identifier must be a UUID.');
}

if (privateKey === undefined) {
return new ApiKey(identifier);
}

if (!ApiKey.PRIVATE_KEY_PATTERN.test(privateKey)) {
throw new Error('The API key private key must be a hexadecimal string.');
}

try {
return new ApiKey(identifier, crypto.createPrivateKey({
key: Buffer.from(privateKey, 'hex'),
format: 'der',
type: 'pkcs8',
}));
} catch {
throw new Error('Invalid private key.');
}
}

public getIdentifier(): string {
return this.identifier;
}

public async getIdentifierHash(): Promise<string> {
const identifierBytes = Buffer.from(this.identifier.replace(/-/g, ''), 'hex');
const rawHash = await crypto.subtle.digest('SHA-256', identifierBytes);

return Buffer.from(rawHash).toString('hex');
}

public hasPrivateKey(): boolean {
return this.privateKey !== undefined;
}

public getPrivateKey(): string|null {
return this.privateKey === undefined
? null
: this.privateKey
.export({format: 'der', type: 'pkcs8'})
.toString('hex');
}

public sign(blob: Buffer): Promise<Buffer> {
const {privateKey} = this;

if (privateKey === undefined) {
return Promise.reject(new Error('The API key does not have a private key.'));
}

return new Promise((resolve, reject) => {
crypto.sign(null, blob, privateKey, (error, signature) => {
if (error == null) {
resolve(signature);
} else {
reject(error);
}
});
});
}

public export(): string {
const privateKey = this.getPrivateKey();

return this.identifier + (privateKey === null ? '' : `:${privateKey}`);
}

public toString(): string {
return '[redacted]';
}
}
36 changes: 0 additions & 36 deletions src/base64Url.ts

This file was deleted.

7 changes: 7 additions & 0 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ export interface ObservableCache extends Cache {

removeListener(listener: CacheListener): void;
}

export namespace ObservableCache {
export function isObservable(cache: Cache): cache is ObservableCache {
return typeof (cache as ObservableCache).addListener === 'function'
&& typeof (cache as ObservableCache).removeListener === 'function';
}
}
77 changes: 77 additions & 0 deletions src/cache/cookieCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {Cache} from './cache';

export type CookieCacheConfiguration = {
name: string,
secure?: boolean,
maxAge?: number,
domain?: string,
path?: string,
sameSite?: 'strict' | 'lax' | 'none',
};

export class CookieCache implements Cache {
private readonly config: CookieCacheConfiguration;

public constructor(config: CookieCacheConfiguration) {
this.config = config;
}

public get(): string | null {
const entries = document.cookie.split(';');

for (const entry of entries) {
const [name, value] = entry.split('=');

if (CookieCache.decode(name).trim() === this.config.name) {
return CookieCache.decode(value.trim());
}
}

return null;
}

public put(value: string): void {
document.cookie = CookieCache.serializeCookie(value, this.config);
}

public clear(): void {
document.cookie = CookieCache.serializeCookie('', {
...this.config,
maxAge: 0,
});
}

private static serializeCookie(value: string, config: CookieCacheConfiguration): string {
const cookie = [`${CookieCache.encode(config.name)}=${CookieCache.encode(value)}`];

if (config.maxAge !== undefined) {
cookie.push(`Max-Age=${config.maxAge}`);
}

if (config.domain !== undefined) {
cookie.push(`Domain=${CookieCache.encode(config.domain)}`);
}

if (config.path !== undefined) {
cookie.push(`Path=${CookieCache.encode(config.path)}`);
}

if (config.secure === true) {
cookie.push('Secure');
}

if (config.sameSite !== undefined) {
cookie.push(`SameSite=${({strict: 'Strict', lax: 'Lax', none: 'None'})[config.sameSite]}`);
}

return cookie.join('; ');
}

private static encode(value: string): string {
return value.replace(/[,; ]+/g, encodeURIComponent);
}

private static decode(value: string): string {
return decodeURIComponent(value);
}
}
27 changes: 22 additions & 5 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {BackoffPolicy, ArbitraryPolicy} from './retry';
import {PersistentQueue, MonitoredQueue, CapacityRestrictedQueue} from './queue';
import {Beacon} from './trackingEvents';
import {CachedTokenStore, TokenStore} from './token';
import {Tracker} from './tracker';
import {Tracker, TrackingEventProcessor} from './tracker';
import {Evaluator} from './evaluator';
import {encodeJson} from './transformer';
import {CidAssigner, CachedAssigner, RemoteAssigner, FixedAssigner} from './cid';
Expand All @@ -25,6 +25,9 @@ import {
SandboxChannel,
} from './channel';
import {ContentFetcher} from './contentFetcher';
import {CookieCache, CookieCacheConfiguration} from './cache/cookieCache';

export type DependencyResolver<T> = (container: Container) => T;

export type Configuration = {
appId: string,
Expand All @@ -40,7 +43,12 @@ export type Configuration = {
beaconQueueSize: number,
logger?: Logger,
urlSanitizer?: UrlSanitizer,
cookie?: {
clientId?: CookieCacheConfiguration,
userToken?: CookieCacheConfiguration,
},
eventMetadata?: {[key: string]: string},
eventProcessor?: DependencyResolver<TrackingEventProcessor>,
};

export class Container {
Expand Down Expand Up @@ -136,6 +144,9 @@ export class Container {
logger: this.getLogger('Tracker'),
channel: this.getBeaconChannel(),
eventMetadata: this.configuration.eventMetadata,
processor: this.configuration.eventProcessor === undefined
? undefined
: this.configuration.eventProcessor(this),
});

const queue = this.getBeaconQueue();
Expand Down Expand Up @@ -170,11 +181,15 @@ export class Container {
private createContext(): Context {
const tokenKey = this.resolveStorageNamespace('token');
const tabKey = this.resolveStorageNamespace('tab');
const browserStorage = this.getLocalStorage();
const browserCache = new LocalStorageCache(browserStorage, tokenKey);
const browserCache = this.configuration.tokenScope === 'global'
&& this.configuration.cookie?.userToken !== undefined
? new CookieCache(this.configuration.cookie.userToken)
: new LocalStorageCache(this.getLocalStorage(), tokenKey);
const tabStorage = this.getSessionStorage();

this.removeTokenSyncListener = LocalStorageCache.autoSync(browserCache);
if (browserCache instanceof LocalStorageCache) {
this.removeTokenSyncListener = LocalStorageCache.autoSync(browserCache);
}

return Context.load({
tokenScope: this.configuration.tokenScope,
Expand Down Expand Up @@ -262,7 +277,9 @@ export class Container {

return new CachedAssigner(
new RemoteAssigner(this.configuration.cidAssignerEndpointUrl, logger),
new LocalStorageCache(this.getLocalStorage(), 'croct.cid'),
this.configuration.cookie?.clientId !== undefined
? new CookieCache(this.configuration.cookie?.clientId)
: new LocalStorageCache(this.getLocalStorage(), 'croct.cid'),
{
logger: logger,
mirror: !this.configuration.disableCidMirroring,
Expand Down
Loading

0 comments on commit ed6abf1

Please sign in to comment.