~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
168 lines · 5.1 KB Raw History
import {
  createAccessJwt,
  createRefreshJwt,
  verifyAccessJwt,
  verifyRefreshJwt,
} from '@pdsjs/core/auth';
import { base64UrlDecode } from '@pdsjs/core/crypto';
import { describe, expect, test } from 'vitest';

describe('JWT Creation', () => {
  test('createAccessJwt creates valid JWT structure', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    const parts = jwt.split('.');
    expect(parts.length).toBe(3);

    // Decode header
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[0])),
    );
    expect(header.typ).toBe('at+jwt');
    expect(header.alg).toBe('HS256');

    // Decode payload
    const payload = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[1])),
    );
    expect(payload.scope).toBe('com.atproto.access');
    expect(payload.sub).toBe(did);
    expect(payload.aud).toBe(did);
    expect(payload.iat > 0).toBe(true);
    expect(payload.exp > payload.iat).toBe(true);
  });

  test('createRefreshJwt creates valid JWT with jti', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    const parts = jwt.split('.');
    const header = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[0])),
    );
    expect(header.typ).toBe('refresh+jwt');

    const payload = JSON.parse(
      new TextDecoder().decode(base64UrlDecode(parts[1])),
    );
    expect(payload.scope).toBe('com.atproto.refresh');
    expect(payload.jti).toBeTruthy(); // has unique token ID
  });
});

describe('JWT Verification', () => {
  test('verifyAccessJwt returns payload for valid token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    const payload = await verifyAccessJwt(jwt, secret);
    expect(payload.sub).toBe(did);
    expect(payload.scope).toBe('com.atproto.access');
  });

  test('verifyAccessJwt throws for wrong secret', async () => {
    const did = 'did:web:test.example';
    const jwt = await createAccessJwt(did, 'correct-secret');

    await expect(() => verifyAccessJwt(jwt, 'wrong-secret')).rejects.toThrow(
      /invalid signature/i,
    );
  });

  test('verifyAccessJwt throws for expired token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    // Create token that expired 1 second ago
    const jwt = await createAccessJwt(did, secret, -1);

    await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow(
      /expired/i,
    );
  });

  test('verifyAccessJwt throws for refresh token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    await expect(() => verifyAccessJwt(jwt, secret)).rejects.toThrow(
      /invalid token type/i,
    );
  });

  test('verifyRefreshJwt returns payload for valid token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createRefreshJwt(did, secret);

    const payload = await verifyRefreshJwt(jwt, secret);
    expect(payload.sub).toBe(did);
    expect(payload.scope).toBe('com.atproto.refresh');
    expect(payload.jti).toBeTruthy(); // has token ID
  });

  test('verifyRefreshJwt throws for wrong secret', async () => {
    const did = 'did:web:test.example';
    const jwt = await createRefreshJwt(did, 'correct-secret');

    await expect(() => verifyRefreshJwt(jwt, 'wrong-secret')).rejects.toThrow(
      /invalid signature/i,
    );
  });

  test('verifyRefreshJwt throws for expired token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    // Create token that expired 1 second ago
    const jwt = await createRefreshJwt(did, secret, -1);

    await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow(
      /expired/i,
    );
  });

  test('verifyRefreshJwt throws for access token', async () => {
    const did = 'did:web:test.example';
    const secret = 'test-secret-key';
    const jwt = await createAccessJwt(did, secret);

    await expect(() => verifyRefreshJwt(jwt, secret)).rejects.toThrow(
      /invalid token type/i,
    );
  });

  test('verifyAccessJwt throws for malformed JWT', async () => {
    const secret = 'test-secret-key';

    // Not a JWT at all
    await expect(() => verifyAccessJwt('not-a-jwt', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    // Only two parts
    await expect(() => verifyAccessJwt('two.parts', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    // Four parts
    await expect(() =>
      verifyAccessJwt('one.two.three.four', secret),
    ).rejects.toThrow(/Invalid JWT format/i);
  });

  test('verifyRefreshJwt throws for malformed JWT', async () => {
    const secret = 'test-secret-key';

    await expect(() => verifyRefreshJwt('not-a-jwt', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );

    await expect(() => verifyRefreshJwt('two.parts', secret)).rejects.toThrow(
      /Invalid JWT format/i,
    );
  });
});