diff --git a/event.ts b/event.ts index da518fa..8e5abaf 100644 --- a/event.ts +++ b/event.ts @@ -27,6 +27,7 @@ export enum Kind { Zap = 9735, RelayList = 10002, ClientAuth = 22242, + HttpAuth = 27235, ProfileBadge = 30008, BadgeDefinition = 30009, Article = 30023 diff --git a/nip98.test.ts b/nip98.test.ts new file mode 100644 index 0000000..3d24d5d --- /dev/null +++ b/nip98.test.ts @@ -0,0 +1,139 @@ +import {base64} from '@scure/base' +import {getToken, validateToken} from './nip98.ts' +import {Event, Kind, finishEvent} from './event.ts' +import {utf8Decoder} from './utils.ts' +import {generatePrivateKey, getPublicKey} from './keys.ts' + +const sk = generatePrivateKey() + +describe('getToken', () => { + test('getToken GET returns without authorization scheme', async () => { + let result = await getToken('http://test.com', 'get', e => + finishEvent(e, sk) + ) + + const decodedResult: Event = JSON.parse( + utf8Decoder.decode(base64.decode(result)) + ) + + expect(decodedResult.created_at).toBeGreaterThan(0) + expect(decodedResult.content).toBe('') + expect(decodedResult.kind).toBe(Kind.HttpAuth) + expect(decodedResult.pubkey).toBe(getPublicKey(sk)) + expect(decodedResult.tags).toStrictEqual([ + ['u', 'http://test.com'], + ['method', 'get'] + ]) + }) + + test('getToken POST returns token without authorization scheme', async () => { + let result = await getToken('http://test.com', 'post', e => + finishEvent(e, sk) + ) + + const decodedResult: Event = JSON.parse( + utf8Decoder.decode(base64.decode(result)) + ) + + expect(decodedResult.created_at).toBeGreaterThan(0) + expect(decodedResult.content).toBe('') + expect(decodedResult.kind).toBe(Kind.HttpAuth) + expect(decodedResult.pubkey).toBe(getPublicKey(sk)) + expect(decodedResult.tags).toStrictEqual([ + ['u', 'http://test.com'], + ['method', 'post'] + ]) + }) + + test('getToken GET returns token WITH authorization scheme', async () => { + const authorizationScheme = 'Nostr ' + + let result = await getToken( + 'http://test.com', + 'post', + e => finishEvent(e, sk), + true + ) + + expect(result.startsWith(authorizationScheme)).toBe(true) + + const decodedResult: Event = JSON.parse( + utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, ''))) + ) + + expect(decodedResult.created_at).toBeGreaterThan(0) + expect(decodedResult.content).toBe('') + expect(decodedResult.kind).toBe(Kind.HttpAuth) + expect(decodedResult.pubkey).toBe(getPublicKey(sk)) + expect(decodedResult.tags).toStrictEqual([ + ['u', 'http://test.com'], + ['method', 'post'] + ]) + }) + + test('getToken unknown method throws an error', async () => { + const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk)) + await expect(result).rejects.toThrow(Error) + }) + + test('getToken missing loginUrl throws an error', async () => { + const result = getToken('', 'get', e => finishEvent(e, sk)) + await expect(result).rejects.toThrow(Error) + }) + + test('getToken missing httpMethod throws an error', async () => { + const result = getToken('http://test.com', '', e => finishEvent(e, sk)) + await expect(result).rejects.toThrow(Error) + }) +}) + +describe('validateToken', () => { + test('validateToken returns true for valid token without authorization scheme', async () => { + const validToken = await getToken('http://test.com', 'get', e => + finishEvent(e, sk) + ) + + const result = await validateToken(validToken, 'http://test.com', 'get') + expect(result).toBe(true) + }) + + test('validateToken returns true for valid token with authorization scheme', async () => { + const validToken = await getToken( + 'http://test.com', + 'get', + e => finishEvent(e, sk), + true + ) + + const result = await validateToken(validToken, 'http://test.com', 'get') + expect(result).toBe(true) + }) + + test('validateToken throws an error for invalid token', async () => { + const result = validateToken('fake', 'http://test.com', 'get') + await expect(result).rejects.toThrow(Error) + }) + + test('validateToken throws an error for missing token', async () => { + const result = validateToken('', 'http://test.com', 'get') + await expect(result).rejects.toThrow(Error) + }) + + test('validateToken throws an error for a wrong url', async () => { + const validToken = await getToken('http://test.com', 'get', e => + finishEvent(e, sk) + ) + + const result = validateToken(validToken, 'http://wrong-test.com', 'get') + await expect(result).rejects.toThrow(Error) + }) + + test('validateToken throws an error for a wrong method', async () => { + const validToken = await getToken('http://test.com', 'get', e => + finishEvent(e, sk) + ) + + const result = validateToken(validToken, 'http://test.com', 'post') + await expect(result).rejects.toThrow(Error) + }) +}) diff --git a/nip98.ts b/nip98.ts new file mode 100644 index 0000000..fc56ae4 --- /dev/null +++ b/nip98.ts @@ -0,0 +1,112 @@ +import {base64} from '@scure/base' +import { + Event, + EventTemplate, + Kind, + getBlankEvent, + verifySignature +} from './event' +import {utf8Decoder, utf8Encoder} from './utils' + +enum HttpMethod { + Get = 'get', + Post = 'post' +} + +const _authorizationScheme = 'Nostr ' + +/** + * Generate token for NIP-98 flow. + * + * @example + * const sign = window.nostr.signEvent + * await getToken('https://example.com/login', 'post', sign, true) + */ +export async function getToken( + loginUrl: string, + httpMethod: HttpMethod | string, + sign: ( + e: EventTemplate + ) => Promise> | Event, + includeAuthorizationScheme: boolean = false +): Promise { + if (!loginUrl || !httpMethod) + throw new Error('Missing loginUrl or httpMethod') + if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post) + throw new Error('Unknown httpMethod') + + const event = getBlankEvent(Kind.HttpAuth) + + event.tags = [ + ['u', loginUrl], + ['method', httpMethod] + ] + event.created_at = Math.round(new Date().getTime() / 1000) + + const signedEvent = await sign(event) + + const authorizationScheme = includeAuthorizationScheme + ? _authorizationScheme + : '' + return ( + authorizationScheme + + base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent))) + ) +} + +/** + * Validate token for NIP-98 flow. + * + * @example + * await validateToken('Nostr base64token', 'https://example.com/login', 'post') + */ +export async function validateToken( + token: string, + url: string, + method: string +): Promise { + if (!token) { + throw new Error('Missing token') + } + token = token.replace(_authorizationScheme, '') + + const eventB64 = utf8Decoder.decode(base64.decode(token)) + if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) { + throw new Error('Invalid token') + } + + const event = JSON.parse(eventB64) as Event + if (!event) { + throw new Error('Invalid nostr event') + } + if (!verifySignature(event)) { + throw new Error('Invalid nostr event, signature invalid') + } + if (event.kind !== Kind.HttpAuth) { + throw new Error('Invalid nostr event, kind invalid') + } + + if (!event.created_at) { + throw new Error('Invalid nostr event, created_at invalid') + } + + // Event must be less than 60 seconds old + if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) { + throw new Error('Invalid nostr event, expired') + } + + const urlTag = event.tags.find(t => t[0] === 'u') + if (urlTag?.length !== 1 && urlTag?.[1] !== url) { + throw new Error('Invalid nostr event, url tag invalid') + } + + const methodTag = event.tags.find(t => t[0] === 'method') + if ( + methodTag?.length !== 1 && + methodTag?.[1].toLowerCase() !== method.toLowerCase() + ) { + throw new Error('Invalid nostr event, method tag invalid') + } + + return true +}