aboutsummaryrefslogtreecommitdiff
path: root/src/token
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 20:36:24 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-14 20:36:24 -0800
commit6bf57766feb8321f860baf300140563cd9539053 (patch)
treed80ff78c2a7f4dbea79f9ee850542aee1b735ef4 /src/token
downloadposthook-6bf57766feb8321f860baf300140563cd9539053.tar.gz
posthook-6bf57766feb8321f860baf300140563cd9539053.zip
Init
Diffstat (limited to 'src/token')
-rw-r--r--src/token/index.ts76
1 files changed, 76 insertions, 0 deletions
diff --git a/src/token/index.ts b/src/token/index.ts
new file mode 100644
index 0000000..7251714
--- /dev/null
+++ b/src/token/index.ts
@@ -0,0 +1,76 @@
+import { createHmac, randomBytes } from 'crypto';
+import { Either, type IEither } from '@emprespresso/pengueno';
+
+export interface TokenPayload {
+ routeName: string;
+ timestamp: number;
+}
+
+export class TokenSigner {
+ private readonly secret: string;
+ private readonly ttlSeconds: number;
+
+ constructor(secret?: string, ttlSeconds: number = 30) {
+ this.secret = secret || randomBytes(32).toString('hex');
+ this.ttlSeconds = ttlSeconds;
+ }
+
+ generate(routeName: string): string {
+ const timestamp = Date.now();
+ const payload = JSON.stringify({ routeName, timestamp });
+ const signature = this.sign(payload);
+ const token = Buffer.from(`${payload}.${signature}`).toString('base64url');
+ return token;
+ }
+
+ validate(token: string, expectedRoute: string): IEither<Error, TokenPayload> {
+ try {
+ const decoded = Buffer.from(token, 'base64url').toString('utf-8');
+ const lastDotIndex = decoded.lastIndexOf('.');
+
+ if (lastDotIndex === -1) {
+ return Either.left(new Error('Invalid token format'));
+ }
+
+ const payload = decoded.substring(0, lastDotIndex);
+ const signature = decoded.substring(lastDotIndex + 1);
+
+ // Verify signature
+ const expectedSignature = this.sign(payload);
+ if (signature !== expectedSignature) {
+ return Either.left(new Error('Invalid token signature'));
+ }
+
+ // Parse payload
+ const parsed: TokenPayload = JSON.parse(payload);
+
+ // Check route name
+ if (parsed.routeName !== expectedRoute) {
+ return Either.left(new Error('Token route mismatch'));
+ }
+
+ // Check expiration
+ const now = Date.now();
+ const age = (now - parsed.timestamp) / 1000;
+ if (age > this.ttlSeconds) {
+ return Either.left(new Error('Token expired'));
+ }
+
+ if (age < 0) {
+ return Either.left(new Error('Token from future'));
+ }
+
+ return Either.right(parsed);
+ } catch (err) {
+ return Either.left(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+
+ private sign(payload: string): string {
+ return createHmac('sha256', this.secret).update(payload).digest('hex');
+ }
+
+ getSecret(): string {
+ return this.secret;
+ }
+}