mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
/**
|
||
* Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login
|
||
* helpers (PKCE, capability detection, URL building, loopback callback
|
||
* parsing, token-response normalization, refresh-timing).
|
||
*
|
||
* Run with: node --test electron/native-oauth.test.ts
|
||
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { createHash } from 'node:crypto'
|
||
|
||
import { test } from 'vitest'
|
||
|
||
import {
|
||
buildNativeAuthorizeUrl,
|
||
generatePkcePair,
|
||
generateState,
|
||
NATIVE_FLOW_ID,
|
||
nativeRefreshUrl,
|
||
nativeTokenUrl,
|
||
parseLoopbackCallback,
|
||
parseTokenResponse,
|
||
resolveLoginStrategy,
|
||
statusSupportsNativeFlow,
|
||
tokenNeedsRefresh
|
||
} from './native-oauth'
|
||
|
||
// --- PKCE ---
|
||
|
||
test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
||
const pair = generatePkcePair()
|
||
|
||
assert.equal(pair.method, 'S256')
|
||
// Verifier length within RFC 7636 range (43–128).
|
||
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
|
||
|
||
// Challenge must be the base64url SHA-256 of the verifier.
|
||
const expected = createHash('sha256')
|
||
.update(pair.verifier, 'ascii')
|
||
.digest('base64')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/, '')
|
||
|
||
assert.equal(pair.challenge, expected)
|
||
// No padding / URL-unsafe chars.
|
||
assert.doesNotMatch(pair.verifier, /[+/=]/)
|
||
assert.doesNotMatch(pair.challenge, /[+/=]/)
|
||
})
|
||
|
||
test('generatePkcePair is unique per call', () => {
|
||
assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier)
|
||
})
|
||
|
||
test('generateState is non-empty and URL-safe', () => {
|
||
const s = generateState()
|
||
|
||
assert.ok(s.length > 0)
|
||
assert.doesNotMatch(s, /[+/=]/)
|
||
})
|
||
|
||
// --- capability detection ---
|
||
|
||
test('statusSupportsNativeFlow reads the auth_flows array', () => {
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true)
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false)
|
||
// Older gateway: no auth_flows field at all ⇒ not supported.
|
||
assert.equal(statusSupportsNativeFlow({ auth_required: true }), false)
|
||
assert.equal(statusSupportsNativeFlow({}), false)
|
||
assert.equal(statusSupportsNativeFlow(null), false)
|
||
// Malformed field shapes never throw.
|
||
assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false)
|
||
})
|
||
|
||
test('resolveLoginStrategy picks native only when advertised and not forced', () => {
|
||
const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] }
|
||
const legacy = { auth_required: true, auth_flows: ['cookie'] }
|
||
|
||
assert.equal(resolveLoginStrategy(gated), 'native')
|
||
// Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded.
|
||
assert.equal(resolveLoginStrategy(legacy), 'embedded')
|
||
// A user/env override can pin the legacy flow even on a capable gateway.
|
||
assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded')
|
||
})
|
||
|
||
// --- URL building ---
|
||
|
||
test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
|
||
const url = buildNativeAuthorizeUrl('https://gw.example.com', {
|
||
challenge: 'CHAL',
|
||
redirectUri: 'http://127.0.0.1:51000/callback',
|
||
state: 'STATE',
|
||
provider: 'nous'
|
||
})
|
||
|
||
const parsed = new URL(url)
|
||
|
||
assert.equal(parsed.origin, 'https://gw.example.com')
|
||
assert.equal(parsed.pathname, '/auth/native/authorize')
|
||
assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL')
|
||
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256')
|
||
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback')
|
||
assert.equal(parsed.searchParams.get('state'), 'STATE')
|
||
assert.equal(parsed.searchParams.get('provider'), 'nous')
|
||
})
|
||
|
||
test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => {
|
||
const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', {
|
||
challenge: 'C',
|
||
redirectUri: 'http://127.0.0.1:1/cb',
|
||
state: 'S'
|
||
})
|
||
|
||
const parsed = new URL(url)
|
||
|
||
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
|
||
assert.equal(parsed.searchParams.get('provider'), null)
|
||
})
|
||
|
||
test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => {
|
||
assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token')
|
||
assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh')
|
||
})
|
||
|
||
// --- loopback callback parsing ---
|
||
|
||
test('parseLoopbackCallback returns the code on a state match', () => {
|
||
const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz')
|
||
|
||
assert.equal(code, 'abc123')
|
||
})
|
||
|
||
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
|
||
assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i)
|
||
})
|
||
|
||
test('parseLoopbackCallback surfaces a gateway error param', () => {
|
||
assert.throws(
|
||
() => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'),
|
||
/access_denied.*nope/i
|
||
)
|
||
})
|
||
|
||
test('parseLoopbackCallback throws when the code is absent', () => {
|
||
assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i)
|
||
})
|
||
|
||
// --- token response normalization ---
|
||
|
||
test('parseTokenResponse maps a well-formed body', () => {
|
||
const t = parseTokenResponse({
|
||
access_token: 'AT',
|
||
refresh_token: 'RT',
|
||
token_type: 'Bearer',
|
||
expires_at: 1893456000,
|
||
provider: 'nous',
|
||
user_id: 'u-1'
|
||
})
|
||
|
||
assert.equal(t.accessToken, 'AT')
|
||
assert.equal(t.refreshToken, 'RT')
|
||
assert.equal(t.expiresAt, 1893456000)
|
||
assert.equal(t.provider, 'nous')
|
||
assert.equal(t.userId, 'u-1')
|
||
})
|
||
|
||
test('parseTokenResponse throws on a missing access token', () => {
|
||
assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i)
|
||
})
|
||
|
||
test('parseTokenResponse tolerates an absent refresh token / expiry', () => {
|
||
const t = parseTokenResponse({ access_token: 'AT' })
|
||
|
||
assert.equal(t.refreshToken, '')
|
||
assert.equal(t.expiresAt, 0)
|
||
})
|
||
|
||
// --- refresh timing ---
|
||
|
||
test('tokenNeedsRefresh respects the skew window', () => {
|
||
const now = 1_000_000
|
||
// Expires comfortably in the future ⇒ no refresh.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false)
|
||
// Within the 60s skew ⇒ refresh early.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true)
|
||
// Already expired ⇒ refresh.
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true)
|
||
// Unknown expiry ⇒ refresh (validate before use).
|
||
assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true)
|
||
})
|