Skip to content

Commit 7b85bd5

Browse files
authored
Merge pull request #62 from kellenmurphy/feat/authn-context-and-xml-highlighting
feat: InCommon attribute annotations, Examples button, and Authorization header fix
2 parents 0751bec + c0ff7a5 commit 7b85bd5

9 files changed

Lines changed: 677 additions & 16 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ But it's not just SAML! It's equally useful for OAuth 2.0 / OIDC work. Access to
2424

2525
## Features
2626

27-
**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.
27+
**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.
2828

29-
**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.
29+
**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.
3030

31-
**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`.
31+
**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.
3232

3333
**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.
3434

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

99101
## What's Planned
100102

101-
- **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
102103
- **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
103104
- **SAML metadata parsing** — parse `EntityDescriptor` XML into a structured view: signing certs, ACS URLs, NameID formats, supported bindings, contacts
104105
- **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.)

src/lib/attributes.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
getAttributeInfo,
4+
eppnScopedStatus,
5+
EPPN_OID,
6+
ATTRIBUTE_REGISTRY,
7+
} from './attributes';
8+
9+
describe('ATTRIBUTE_REGISTRY', () => {
10+
it('EPPN_OID constant matches the registry entry', () => {
11+
expect(ATTRIBUTE_REGISTRY[EPPN_OID]?.friendlyName).toBe('eduPersonPrincipalName');
12+
});
13+
14+
it('contains entries for core eduPerson OIDs', () => {
15+
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.9']?.friendlyName).toBe(
16+
'eduPersonScopedAffiliation'
17+
);
18+
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.10']?.friendlyName).toBe(
19+
'eduPersonTargetedID'
20+
);
21+
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.12']?.friendlyName).toBe(
22+
'eduPersonUniqueId'
23+
);
24+
});
25+
26+
it('contains entries for standard LDAP attributes', () => {
27+
expect(ATTRIBUTE_REGISTRY['urn:oid:0.9.2342.19200300.100.1.3']?.friendlyName).toBe('mail');
28+
expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.42']?.friendlyName).toBe('givenName');
29+
expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.4']?.friendlyName).toBe('sn');
30+
expect(ATTRIBUTE_REGISTRY['urn:oid:2.16.840.1.113730.3.1.241']?.friendlyName).toBe(
31+
'displayName'
32+
);
33+
});
34+
35+
it('contains entries for SCHAC and isMemberOf', () => {
36+
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.25178.1.2.9']?.friendlyName).toBe(
37+
'schacHomeOrganization'
38+
);
39+
expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.5.1.1']?.friendlyName).toBe(
40+
'isMemberOf'
41+
);
42+
});
43+
});
44+
45+
describe('getAttributeInfo', () => {
46+
it('returns rs category for eduPersonPrincipalName', () => {
47+
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.6');
48+
expect(info?.categories).toContain('rs');
49+
});
50+
51+
it('returns rs category for eduPersonScopedAffiliation', () => {
52+
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.9');
53+
expect(info?.categories).toContain('rs');
54+
});
55+
56+
it('returns rs for eduPersonTargetedID', () => {
57+
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.10');
58+
expect(info?.categories).toContain('rs');
59+
});
60+
61+
it('returns rs for eduPersonUniqueId', () => {
62+
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.12');
63+
expect(info?.categories).toContain('rs');
64+
});
65+
66+
it('returns empty categories for eduPersonEntitlement (not in R&S bundle)', () => {
67+
const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.7');
68+
expect(info?.categories).toHaveLength(0);
69+
});
70+
71+
it('returns empty categories for attributes outside the R&S bundle', () => {
72+
const info = getAttributeInfo('urn:oid:2.5.4.3');
73+
expect(info?.friendlyName).toBe('cn');
74+
expect(info?.categories).toHaveLength(0);
75+
});
76+
77+
it('returns undefined for unknown OIDs', () => {
78+
expect(getAttributeInfo('urn:oid:9.9.9.9.9')).toBeUndefined();
79+
expect(getAttributeInfo('unknown:attribute')).toBeUndefined();
80+
});
81+
});
82+
83+
describe('eppnScopedStatus', () => {
84+
it('returns scoped when ePPN value contains @', () => {
85+
expect(eppnScopedStatus(EPPN_OID, ['user@example.edu'])).toBe('scoped');
86+
});
87+
88+
it('returns unscoped when ePPN value has no @', () => {
89+
expect(eppnScopedStatus(EPPN_OID, ['username'])).toBe('unscoped');
90+
});
91+
92+
it('returns null when ePPN has no values', () => {
93+
expect(eppnScopedStatus(EPPN_OID, [])).toBeNull();
94+
});
95+
96+
it('returns null for non-ePPN attributes even with a scoped-looking value', () => {
97+
expect(
98+
eppnScopedStatus('urn:oid:0.9.2342.19200300.100.1.3', ['user@example.edu'])
99+
).toBeNull();
100+
});
101+
102+
it('returns null for completely unrecognized attribute names', () => {
103+
expect(eppnScopedStatus('unknown:attr', ['value'])).toBeNull();
104+
});
105+
});

src/lib/attributes.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
export type AttributeCategory = 'rs';
2+
3+
export interface AttributeInfo {
4+
friendlyName: string;
5+
categories: AttributeCategory[];
6+
}
7+
8+
export const EPPN_OID = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6';
9+
10+
export const ATTRIBUTE_REGISTRY: Record<string, AttributeInfo> = {
11+
// ── eduPerson ─────────────────────────────────────────────────────────────────
12+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.1': { friendlyName: 'eduPersonAffiliation', categories: [] },
13+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.2': { friendlyName: 'eduPersonNickname', categories: [] },
14+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.5': { friendlyName: 'eduPersonPrimaryAffiliation', categories: [] },
15+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.6': { friendlyName: 'eduPersonPrincipalName', categories: ['rs'] },
16+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.7': { friendlyName: 'eduPersonEntitlement', categories: [] },
17+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.9': { friendlyName: 'eduPersonScopedAffiliation', categories: ['rs'] },
18+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.10': { friendlyName: 'eduPersonTargetedID', categories: ['rs'] },
19+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.11': { friendlyName: 'eduPersonAssurance', categories: [] },
20+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.12': { friendlyName: 'eduPersonUniqueId', categories: ['rs'] },
21+
'urn:oid:1.3.6.1.4.1.5923.1.1.1.13': { friendlyName: 'eduPersonOrcid', categories: [] },
22+
// ── inetOrgPerson / person ────────────────────────────────────────────────────
23+
'urn:oid:0.9.2342.19200300.100.1.1': { friendlyName: 'uid', categories: [] },
24+
'urn:oid:0.9.2342.19200300.100.1.3': { friendlyName: 'mail', categories: ['rs'] },
25+
'urn:oid:0.9.2342.19200300.100.1.37': { friendlyName: 'labeledUri', categories: [] },
26+
'urn:oid:0.9.2342.19200300.100.1.41': { friendlyName: 'mobile', categories: [] },
27+
'urn:oid:2.5.4.3': { friendlyName: 'cn', categories: [] },
28+
'urn:oid:2.5.4.4': { friendlyName: 'sn', categories: ['rs'] },
29+
'urn:oid:2.5.4.6': { friendlyName: 'c', categories: [] },
30+
'urn:oid:2.5.4.7': { friendlyName: 'l', categories: [] },
31+
'urn:oid:2.5.4.8': { friendlyName: 'st', categories: [] },
32+
'urn:oid:2.5.4.9': { friendlyName: 'street', categories: [] },
33+
'urn:oid:2.5.4.10': { friendlyName: 'o', categories: [] },
34+
'urn:oid:2.5.4.11': { friendlyName: 'ou', categories: [] },
35+
'urn:oid:2.5.4.12': { friendlyName: 'title', categories: [] },
36+
'urn:oid:2.5.4.17': { friendlyName: 'postalCode', categories: [] },
37+
'urn:oid:2.5.4.20': { friendlyName: 'telephoneNumber', categories: [] },
38+
'urn:oid:2.5.4.42': { friendlyName: 'givenName', categories: ['rs'] },
39+
'urn:oid:2.16.840.1.113730.3.1.2': { friendlyName: 'employeeType', categories: [] },
40+
'urn:oid:2.16.840.1.113730.3.1.3': { friendlyName: 'employeeNumber', categories: [] },
41+
'urn:oid:2.16.840.1.113730.3.1.4': { friendlyName: 'departmentNumber', categories: [] },
42+
'urn:oid:2.16.840.1.113730.3.1.39': { friendlyName: 'preferredLanguage', categories: [] },
43+
'urn:oid:2.16.840.1.113730.3.1.241': { friendlyName: 'displayName', categories: ['rs'] },
44+
// ── SCHAC ─────────────────────────────────────────────────────────────────────
45+
'urn:oid:1.3.6.1.4.1.25178.1.2.9': { friendlyName: 'schacHomeOrganization', categories: [] },
46+
'urn:oid:1.3.6.1.4.1.25178.1.2.10': { friendlyName: 'schacHomeOrganizationType', categories: [] },
47+
'urn:oid:1.3.6.1.4.1.25178.1.2.14': { friendlyName: 'schacPersonalUniqueCode', categories: [] },
48+
'urn:oid:1.3.6.1.4.1.25178.1.2.15': { friendlyName: 'schacPersonalUniqueID', categories: [] },
49+
// ── Grouper / isMemberOf ──────────────────────────────────────────────────────
50+
'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': { friendlyName: 'isMemberOf', categories: [] },
51+
};
52+
53+
export function getAttributeInfo(name: string): AttributeInfo | undefined {
54+
return ATTRIBUTE_REGISTRY[name];
55+
}
56+
57+
// Returns 'scoped' if the ePPN value contains @scope, 'unscoped' if not, null if not an ePPN attribute.
58+
export function eppnScopedStatus(name: string, values: string[]): 'scoped' | 'unscoped' | null {
59+
if (name !== EPPN_OID) return null;
60+
if (values.length === 0) return null;
61+
return values[0].includes('@') ? 'scoped' : 'unscoped';
62+
}

0 commit comments

Comments
 (0)