57 lines
4.5 KiB
TypeScript
57 lines
4.5 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {base32,totp,matchingStep,newAuthConfig,signSession,validSession,sessionCookie,safeReturn,authConfig,isLoginRedirect} from '../src/lib/auth';
|
|
import {proxy} from '../src/proxy';
|
|
import {NextRequest} from 'next/server';
|
|
import {mkdtempSync,writeFileSync,rmSync} from 'node:fs';
|
|
import {tmpdir} from 'node:os';
|
|
import {join} from 'node:path';
|
|
test('TOTP matches RFC 6238 SHA-1 test vectors',()=>{
|
|
const secret=base32(Buffer.from('12345678901234567890'));
|
|
for(const [time,expected] of [[59,'94287082'],[1111111109,'07081804'],[1111111111,'14050471'],[1234567890,'89005924'],[2000000000,'69279037'],[20000000000,'65353130']] as const) assert.equal(totp(secret,Math.floor(time/30),8),expected);
|
|
assert.equal(matchingStep(secret,totp(secret,100),3000000),100);
|
|
assert.equal(matchingStep(secret,totp(secret,99),3000000),99);
|
|
assert.equal(matchingStep(secret,totp(secret,98),3000000),null);
|
|
assert.equal(matchingStep(secret,'123',3000000),null);
|
|
});
|
|
test('JWT rejects tampering, expiry, wrong keys and malformed values',()=>{
|
|
const config=newAuthConfig(),token=signSession(config,1000);
|
|
assert.equal(validSession(token,config,1000),true);
|
|
assert.equal(validSession(token,config,1000+604800),false);
|
|
assert.equal(validSession(token,config,900),false);
|
|
assert.equal(validSession(token,newAuthConfig(),1000),false);
|
|
assert.equal(validSession(token+'x',config,1000),false);
|
|
assert.equal(validSession('eyJhbGciOiJub25lIn0.e30.',config,1000),false);
|
|
assert.equal(validSession(undefined,config),false);
|
|
});
|
|
test('cookies are HTTP-only and secure by default; redirects stay local',()=>{
|
|
assert.match(sessionCookie('test'),/HttpOnly; SameSite=Lax; Max-Age=604800; Secure$/);
|
|
assert.match(sessionCookie('',true),/Max-Age=0/);
|
|
for(const path of ['https://evil.test','//evil.test','/\\evil.test','/login?next=/','/\nLocation: bad']) assert.equal(safeReturn(path),'/');
|
|
assert.equal(safeReturn('/explore/123?q=test'),'/explore/123?q=test');
|
|
});
|
|
test('proxy protects APIs, data routes, static content and Studio; fails closed',()=>{
|
|
const temp=mkdtempSync(join(tmpdir(),'prompt-auth-')); const old=process.env.AUTH_CONFIG_FILE;
|
|
try{
|
|
const config=newAuthConfig(); process.env.AUTH_CONFIG_FILE=join(temp,'config.json'); writeFileSync(process.env.AUTH_CONFIG_FILE,JSON.stringify(config));
|
|
for(const path of ['/library/import','/skills/example.png','/_next/data/build/index.json','/studio']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,307,path);
|
|
for(const path of ['/api/upload','/studio/api/export','/api/feed']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,401,path);
|
|
for(const path of ['/','/guide/welcome','/protocol','/api/guides/welcome','/account','/api/account/session','/login','/brand-mark.svg','/_next/static/chunk.js','/api/health','/api/ready']) assert.equal(proxy(new NextRequest('http://wiki.test'+path)).status,200,path);
|
|
const headers={cookie:'prompt_session='+signSession(config),host:'wiki.test'};
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/api/feed',{headers})).status,200);
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://evil.test'}})).status,403);
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{...headers,origin:'http://wiki.test'}})).status,200);
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts',{method:'POST',headers:{cookie:'wiki_account='+ 'a'.repeat(64)}})).status,401);
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/guide/welcome',{method:'POST'})).status,401);
|
|
writeFileSync(process.env.AUTH_CONFIG_FILE,'{}'); assert.throws(()=>authConfig()); assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503);
|
|
delete process.env.AUTH_CONFIG_FILE;
|
|
assert.equal(proxy(new NextRequest('http://wiki.test/api/posts')).status,503,'Missing admin configuration fails closed');
|
|
}finally{if(old===undefined)delete process.env.AUTH_CONFIG_FILE;else process.env.AUTH_CONFIG_FILE=old;rmSync(temp,{recursive:true,force:true});}
|
|
});
|
|
|
|
test('login redirects recognize browser pages and Next.js data destinations',()=>{
|
|
const base='http://wiki.test';
|
|
for(const target of ['/login?next=%2F','http://wiki.test/login','/_next/data/build-123/login.json?next=%2F']) assert.equal(isLoginRedirect(target,base),true,target);
|
|
for(const target of [null,'','https://evil.test/login','//evil.test/login','/api/login.json','/_next/data/build-123/index.json']) assert.equal(isLoginRedirect(target,base),false,String(target));
|
|
});
|