Skip to content
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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ But it's not just SAML! It's equally useful for OAuth 2.0 / OIDC work. Access to

## Features

**Input** — paste just about anything: raw base64+DEFLATE or base64 blob, query string, full URL, raw HTTP log line, JWT, or `Authorization: Bearer` header. Multiple SAML messages in one paste are handled. RelayState is always extracted separately; double URL-encoding is unwound automatically. `ctrl-a` in your logs and paste away — it'll make sense of it.
**Input** — paste just about anything: raw base64+DEFLATE or base64 blob, query string, full URL, raw HTTP log line, JWT, or `Authorization: Bearer` header. Multiple SAML messages in one paste are handled. RelayState is always extracted separately; double URL-encoding is unwound automatically. `ctrl-a` in your logs and paste away — it'll make sense of it. An **Examples** button loads any of 10 pre-built, dynamically-generated payloads (SAMLResponse, SAMLRequest, JWT, Authorization header, query string, POST binding form value, and full redirect URL) as one-click starting points.

**SAML** — binding type, message type, status with human-readable description and spec link, issuer, NameID, timestamps with relative labels ("expired 3 hours ago"), AuthnContext class reference with friendly label and assurance-level tooltip (OASIS, REFEDS, RAF, NIST), attribute table, signing cert details (key algorithm, validity), and syntax-highlighted XML with hover tips on all known SAML element names.
**SAML** — binding type, message type, status with human-readable description and spec link, issuer, NameID, timestamps with relative labels ("expired 3 hours ago"), AuthnContext class reference with friendly label and assurance-level tooltip (OASIS, REFEDS, RAF, NIST), attribute table with **InCommon attribute annotations** (R&S and eppn-scoped/unscoped badges from an embedded attribute registry; friendly names filled in even when the assertion omits them), signing cert details (key algorithm, validity), and syntax-highlighted XML with hover tips on all known SAML element names.

**JWT** — algorithm with safety flags (`alg: none` danger badge, HMAC weak badge), claims summary, timestamps with relative labels, scope/scp badge list, raw JSON header and payload. OIDC discovery fetches the issuer's `.well-known/openid-configuration` server-side (Cloudflare Worker, avoids CORS) and checks algorithm support against the token's `alg`.
**JWT** — algorithm with safety flags (`alg: none` danger badge, HMAC weak badge), claims summary, timestamps with relative labels, scope/scp badge list, raw JSON header and payload. OIDC discovery fetches the issuer's `.well-known/openid-configuration` server-side (Cloudflare Worker, avoids CORS) and checks algorithm support against the token's `alg`. Accepts bare JWTs, `Bearer <token>`, or full `Authorization: Bearer <token>` header lines.

**Everything else** — contextual `?` tooltips on every field covering the SAML spec, JWT/OIDC standards, and trust fabric conventions. Shareable links base64url-encode the input into the URL fragment — never sent to the server. Dark mode default with `localStorage` persistence.

Expand Down Expand Up @@ -74,6 +74,8 @@ src/
generic.ts # fallback decoder for unrecognized base64/JSON/XML blobs
hash.ts # base64url encode/decode for shareable URL fragments
xml-highlight.ts # custom XML tokenizer: syntax-colored HTML spans + element tooltips
attributes.ts # InCommon attribute registry: R&S categories, eppn-scoped detection
examples.ts # 10 dynamically-generated example payloads (SAML, JWT, query strings, URLs)
explanations.ts # externalized hover tooltip text for all summary fields
InfoTip.svelte # hover tooltip component
routes/
Expand All @@ -98,7 +100,6 @@ src/

## What's Planned

- **InCommon attribute annotations** — flag attributes in the attribute table as InCommon Baseline Eligible, R&S, or REFEDS RAF/eppn-scoped; a clear differentiator vs generic SAML tools
- **Diff view** — paste two assertions side-by-side and highlight what changed; most useful for attribute table and timestamp diffs when debugging why a second login attempt looks different
- **SAML metadata parsing** — parse `EntityDescriptor` XML into a structured view: signing certs, ACS URLs, NameID formats, supported bindings, contacts
- **MDQ discovery** — "Discover" button on the SAML Issuer row fetches the IdP's metadata from InCommon's MDQ service (`https://mdq.incommon.org/entities/{entityID}`) — no aggregate download needed; optional MDQ base URL for other federations (eduGAIN, etc.)
Expand Down
105 changes: 105 additions & 0 deletions src/lib/attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import {
getAttributeInfo,
eppnScopedStatus,
EPPN_OID,
ATTRIBUTE_REGISTRY,
} from './attributes';

describe('ATTRIBUTE_REGISTRY', () => {
it('EPPN_OID constant matches the registry entry', () => {
expect(ATTRIBUTE_REGISTRY[EPPN_OID]?.friendlyName).toBe('eduPersonPrincipalName');
});

it('contains entries for core eduPerson OIDs', () => {
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.9']?.friendlyName).toBe(
'eduPersonScopedAffiliation'
);
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.10']?.friendlyName).toBe(
'eduPersonTargetedID'
);
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.12']?.friendlyName).toBe(
'eduPersonUniqueId'
);
});

it('contains entries for standard LDAP attributes', () => {
expect(ATTRIBUTE_REGISTRY['urn:oid:0.9.2342.19200300.100.1.3']?.friendlyName).toBe('mail');
expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.42']?.friendlyName).toBe('givenName');
expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.4']?.friendlyName).toBe('sn');
expect(ATTRIBUTE_REGISTRY['urn:oid:2.16.840.1.113730.3.1.241']?.friendlyName).toBe(
'displayName'
);
});

it('contains entries for SCHAC and isMemberOf', () => {
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.25178.1.2.9']?.friendlyName).toBe(
'schacHomeOrganization'
);
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.5.1.1']?.friendlyName).toBe(
'isMemberOf'
);
});
});

describe('getAttributeInfo', () => {
it('returns rs category for eduPersonPrincipalName', () => {
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.6');
expect(info?.categories).toContain('rs');
});

it('returns rs category for eduPersonScopedAffiliation', () => {
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.9');
expect(info?.categories).toContain('rs');
});

it('returns rs for eduPersonTargetedID', () => {
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.10');
expect(info?.categories).toContain('rs');
});

it('returns rs for eduPersonUniqueId', () => {
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.12');
expect(info?.categories).toContain('rs');
});

it('returns empty categories for eduPersonEntitlement (not in R&S bundle)', () => {
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.7');
expect(info?.categories).toHaveLength(0);
});

it('returns empty categories for attributes outside the R&S bundle', () => {
const info = getAttributeInfo('urn:oid:2.5.4.3');
expect(info?.friendlyName).toBe('cn');
expect(info?.categories).toHaveLength(0);
});

it('returns undefined for unknown OIDs', () => {
expect(getAttributeInfo('urn:oid:9.9.9.9.9')).toBeUndefined();
expect(getAttributeInfo('unknown:attribute')).toBeUndefined();
});
});

describe('eppnScopedStatus', () => {
it('returns scoped when ePPN value contains @', () => {
expect(eppnScopedStatus(EPPN_OID, ['user@example.edu'])).toBe('scoped');
});

it('returns unscoped when ePPN value has no @', () => {
expect(eppnScopedStatus(EPPN_OID, ['username'])).toBe('unscoped');
});

it('returns null when ePPN has no values', () => {
expect(eppnScopedStatus(EPPN_OID, [])).toBeNull();
});

it('returns null for non-ePPN attributes even with a scoped-looking value', () => {
expect(
eppnScopedStatus('urn:oid:0.9.2342.19200300.100.1.3', ['user@example.edu'])
).toBeNull();
});

it('returns null for completely unrecognized attribute names', () => {
expect(eppnScopedStatus('unknown:attr', ['value'])).toBeNull();
});
});
62 changes: 62 additions & 0 deletions src/lib/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type AttributeCategory = 'rs';

export interface AttributeInfo {
friendlyName: string;
categories: AttributeCategory[];
}

export const EPPN_OID = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6';

export const ATTRIBUTE_REGISTRY: Record<string, AttributeInfo> = {
// ── eduPerson ─────────────────────────────────────────────────────────────────
'urn:oid:1.3.6.1.4.1.5923.1.1.1.1': { friendlyName: 'eduPersonAffiliation', categories: [] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.2': { friendlyName: 'eduPersonNickname', categories: [] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.5': { friendlyName: 'eduPersonPrimaryAffiliation', categories: [] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.6': { friendlyName: 'eduPersonPrincipalName', categories: ['rs'] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.7': { friendlyName: 'eduPersonEntitlement', categories: [] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.9': { friendlyName: 'eduPersonScopedAffiliation', categories: ['rs'] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.10': { friendlyName: 'eduPersonTargetedID', categories: ['rs'] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.11': { friendlyName: 'eduPersonAssurance', categories: [] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.12': { friendlyName: 'eduPersonUniqueId', categories: ['rs'] },
'urn:oid:1.3.6.1.4.1.5923.1.1.1.13': { friendlyName: 'eduPersonOrcid', categories: [] },
// ── inetOrgPerson / person ────────────────────────────────────────────────────
'urn:oid:0.9.2342.19200300.100.1.1': { friendlyName: 'uid', categories: [] },
'urn:oid:0.9.2342.19200300.100.1.3': { friendlyName: 'mail', categories: ['rs'] },
'urn:oid:0.9.2342.19200300.100.1.37': { friendlyName: 'labeledUri', categories: [] },
'urn:oid:0.9.2342.19200300.100.1.41': { friendlyName: 'mobile', categories: [] },
'urn:oid:2.5.4.3': { friendlyName: 'cn', categories: [] },
'urn:oid:2.5.4.4': { friendlyName: 'sn', categories: ['rs'] },
'urn:oid:2.5.4.6': { friendlyName: 'c', categories: [] },
'urn:oid:2.5.4.7': { friendlyName: 'l', categories: [] },
'urn:oid:2.5.4.8': { friendlyName: 'st', categories: [] },
'urn:oid:2.5.4.9': { friendlyName: 'street', categories: [] },
'urn:oid:2.5.4.10': { friendlyName: 'o', categories: [] },
'urn:oid:2.5.4.11': { friendlyName: 'ou', categories: [] },
'urn:oid:2.5.4.12': { friendlyName: 'title', categories: [] },
'urn:oid:2.5.4.17': { friendlyName: 'postalCode', categories: [] },
'urn:oid:2.5.4.20': { friendlyName: 'telephoneNumber', categories: [] },
'urn:oid:2.5.4.42': { friendlyName: 'givenName', categories: ['rs'] },
'urn:oid:2.16.840.1.113730.3.1.2': { friendlyName: 'employeeType', categories: [] },
'urn:oid:2.16.840.1.113730.3.1.3': { friendlyName: 'employeeNumber', categories: [] },
'urn:oid:2.16.840.1.113730.3.1.4': { friendlyName: 'departmentNumber', categories: [] },
'urn:oid:2.16.840.1.113730.3.1.39': { friendlyName: 'preferredLanguage', categories: [] },
'urn:oid:2.16.840.1.113730.3.1.241': { friendlyName: 'displayName', categories: ['rs'] },
// ── SCHAC ─────────────────────────────────────────────────────────────────────
'urn:oid:1.3.6.1.4.1.25178.1.2.9': { friendlyName: 'schacHomeOrganization', categories: [] },
'urn:oid:1.3.6.1.4.1.25178.1.2.10': { friendlyName: 'schacHomeOrganizationType', categories: [] },
'urn:oid:1.3.6.1.4.1.25178.1.2.14': { friendlyName: 'schacPersonalUniqueCode', categories: [] },
'urn:oid:1.3.6.1.4.1.25178.1.2.15': { friendlyName: 'schacPersonalUniqueID', categories: [] },
// ── Grouper / isMemberOf ──────────────────────────────────────────────────────
'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': { friendlyName: 'isMemberOf', categories: [] },
};

export function getAttributeInfo(name: string): AttributeInfo | undefined {
return ATTRIBUTE_REGISTRY[name];
}

// Returns 'scoped' if the ePPN value contains @scope, 'unscoped' if not, null if not an ePPN attribute.
export function eppnScopedStatus(name: string, values: string[]): 'scoped' | 'unscoped' | null {
if (name !== EPPN_OID) return null;
if (values.length === 0) return null;
return values[0].includes('@') ? 'scoped' : 'unscoped';
}
Loading