commit
cf25e28c28
156 files changed
+38669
No files matched your search
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two isolated 2FAuth instances without emitting response bodies/secrets.
|
||||
No redirects or retries. Cookie sessions require explicit opt-in. See compatibility/README.md.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import copy
|
||||
import http.cookiejar
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
DEFAULT_CASES = [
|
||||
{'id': 'health', 'method': 'GET', 'path': '/up'},
|
||||
{'id': 'manifest', 'method': 'GET', 'path': '/manifest.json'},
|
||||
{'id': 'deprecated-name', 'method': 'GET', 'path': '/api/v1/user/name'},
|
||||
{'id': 'unknown-api', 'method': 'GET', 'path': '/api/v1/__compat_missing__'},
|
||||
{'id': 'cors-preflight', 'method': 'OPTIONS', 'path': '/api/v1/twofaccounts', 'expected_status': 204,
|
||||
'headers': {'Origin': 'https://example.invalid', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'Authorization,Content-Type'},
|
||||
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Methods', 'Access-Control-Allow-Headers', 'Access-Control-Allow-Credentials'],
|
||||
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
|
||||
{'id': 'cors-actual', 'method': 'GET', 'path': '/api/v1/twofaccounts', 'expected_status': 401,
|
||||
'headers': {'Origin': 'https://example.invalid'},
|
||||
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials'],
|
||||
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
|
||||
{'id': 'cors-without-origin', 'method': 'GET', 'path': '/api/v1/twofaccounts', 'expected_status': 401,
|
||||
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials'],
|
||||
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
|
||||
]
|
||||
SAFE_CASES = {(c.get('method', 'GET'), c['path']) for c in DEFAULT_CASES}
|
||||
MAX_BYTES = 4 * 1024 * 1024
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs): return None
|
||||
|
||||
def base_url(value):
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError('base must be HTTP(S), without credentials, query or fragment')
|
||||
return value.rstrip('/')
|
||||
|
||||
def fetch(base, case, side, timeout, opener=None, cookies=None):
|
||||
headers = {'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}
|
||||
headers.update(case.get('headers', {}))
|
||||
# Values never appear in output. Side-specific credentials refer to env names.
|
||||
env_headers = case.get('headers_env', {}).get(side, {})
|
||||
for header, name in env_headers.items():
|
||||
if name not in os.environ: return {'error': 'missing_environment_variable'}
|
||||
headers[header] = os.environ[name]
|
||||
for header, cookie_name in case.get('headers_from_cookies', {}).items():
|
||||
matches = [c for c in (cookies or []) if c.name == cookie_name]
|
||||
if len(matches) != 1: return {'error': 'missing_or_ambiguous_cookie'}
|
||||
headers[header] = urllib.parse.unquote(matches[0].value)
|
||||
data = None
|
||||
if sum(name in case for name in ('json','form','raw')) > 1:
|
||||
raise ValueError('a request must choose one body encoding')
|
||||
if 'json' in case:
|
||||
data = json.dumps(case['json']).encode()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
elif 'form' in case:
|
||||
data = urllib.parse.urlencode(case['form'], doseq=True).encode()
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
elif 'raw' in case:
|
||||
data = case['raw'].encode()
|
||||
req = urllib.request.Request(base + case['path'], data=data, headers=headers, method=case.get('method', 'GET'))
|
||||
try:
|
||||
try: response = (opener or urllib.request.build_opener(NoRedirect())).open(req, timeout=timeout)
|
||||
except urllib.error.HTTPError as exc: response = exc
|
||||
with response:
|
||||
body = response.read(MAX_BYTES + 1)
|
||||
if len(body) > MAX_BYTES: return {'error': 'response_too_large'}
|
||||
result = {'status': response.code, 'content_type': response.headers.get('Content-Type', '').split(';')[0].lower(), 'body': body, 'headers': response.headers}
|
||||
return result
|
||||
except Exception as exc:
|
||||
# Exception text can contain URLs or credentials, so only emit class name.
|
||||
return {'error': type(exc).__name__}
|
||||
|
||||
def remove_pointer(value, pointer):
|
||||
if not pointer.startswith('/'): raise ValueError('ignored JSON pointers must start with /')
|
||||
parts = [s.replace('~1', '/').replace('~0', '~') for s in pointer[1:].split('/')]
|
||||
cursor = value
|
||||
for part in parts[:-1]:
|
||||
try: cursor = cursor[int(part)] if isinstance(cursor, list) else cursor[part]
|
||||
except (KeyError, IndexError, ValueError, TypeError): return
|
||||
if isinstance(cursor, dict): cursor.pop(parts[-1], None)
|
||||
elif isinstance(cursor, list):
|
||||
try: cursor[int(parts[-1])] = None
|
||||
except (IndexError, ValueError): pass
|
||||
|
||||
def comparable(response, base, case):
|
||||
body = response['body'].decode('utf-8', errors='replace')
|
||||
if response['content_type'] == 'application/json':
|
||||
try:
|
||||
body = json.loads(body)
|
||||
def normalize(value):
|
||||
if isinstance(value, str): return value.replace(base, '<BASE>')
|
||||
if isinstance(value, dict): return {key: normalize(item) for key, item in value.items()}
|
||||
if isinstance(value, list): return [normalize(item) for item in value]
|
||||
return value
|
||||
body = normalize(body)
|
||||
for pointer in case.get('ignore_json_pointers', []): remove_pointer(body, pointer)
|
||||
except json.JSONDecodeError: body = body.replace(base, '<BASE>')
|
||||
else:
|
||||
body = body.replace(base, '<BASE>')
|
||||
return body
|
||||
|
||||
|
||||
def resolve(value, variables):
|
||||
"""Resolve explicit {{capture}} references, keeping typed whole-value JSON."""
|
||||
if isinstance(value, dict): return {k: resolve(v, variables) for k, v in value.items()}
|
||||
if isinstance(value, list): return [resolve(v, variables) for v in value]
|
||||
if not isinstance(value, str): return value
|
||||
match = re.fullmatch(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}', value)
|
||||
if match: return variables[match[1]]
|
||||
return re.sub(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}', lambda m: str(variables[m[1]]), value)
|
||||
|
||||
|
||||
def read_pointer(value, pointer):
|
||||
if pointer and not pointer.startswith('/'): raise ValueError('invalid JSON pointer')
|
||||
for token in pointer[1:].split('/') if pointer else []:
|
||||
token = token.replace('~1', '/').replace('~0', '~')
|
||||
value = value[int(token)] if isinstance(value, list) else value[token]
|
||||
return value
|
||||
|
||||
|
||||
def is_text_token(response):
|
||||
return response.get('status') == 200 and response.get('content_type') in ('text/plain', 'text/html') and re.fullmatch(rb'[A-Za-z0-9_-]{32,256}', response.get('body', b'')) is not None
|
||||
|
||||
|
||||
def assert_json_response(response, case):
|
||||
try:
|
||||
payload = json.loads(response['body'])
|
||||
for pointer, expected in case.get('expected_json', {}).items():
|
||||
if read_pointer(payload, pointer) != expected: return False
|
||||
for pointer in case.get('absent_json_pointers', []):
|
||||
try: read_pointer(payload, pointer)
|
||||
except (KeyError, IndexError, TypeError, ValueError): continue
|
||||
return False
|
||||
return True
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def capture_values(response, captures, variables):
|
||||
if not captures: return
|
||||
payload = json.loads(response['body'])
|
||||
for name, pointer in captures.items():
|
||||
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name): raise ValueError('invalid capture name')
|
||||
if pointer and not pointer.startswith('/'): raise ValueError('invalid capture pointer')
|
||||
value = payload
|
||||
for token in pointer[1:].split('/') if pointer else []:
|
||||
token = token.replace('~1', '/').replace('~0', '~')
|
||||
value = value[int(token)] if isinstance(value, list) else value[token]
|
||||
variables[name] = value
|
||||
|
||||
|
||||
def validate_path(path):
|
||||
if not isinstance(path, str) or not path.startswith('/') or path.startswith('//') or any(c in path for c in '#\r\n'):
|
||||
raise ValueError('case paths must be absolute local paths without fragments or newlines')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--reference', required=True, type=base_url)
|
||||
parser.add_argument('--candidate', required=True, type=base_url)
|
||||
parser.add_argument('--cases', type=Path)
|
||||
parser.add_argument('--allow-side-effects', action='store_true')
|
||||
parser.add_argument('--timeout', type=float, default=10)
|
||||
parser.add_argument('--sessions', action='store_true', help='retain separate in-memory cookie jars; requires --allow-side-effects')
|
||||
args = parser.parse_args()
|
||||
cases = json.loads(args.cases.read_text()) if args.cases else DEFAULT_CASES
|
||||
if args.reference == args.candidate: parser.error('reference and candidate must be separate instances')
|
||||
if args.sessions and not args.allow_side_effects: parser.error('--sessions requires --allow-side-effects')
|
||||
cookies = {side: http.cookiejar.CookieJar() for side in ('reference', 'candidate')}
|
||||
openers = {side: urllib.request.build_opener(NoRedirect(), urllib.request.HTTPCookieProcessor(cookies[side])) for side in cookies}
|
||||
variables = {side: {} for side in cookies}
|
||||
failed = False
|
||||
for index, case in enumerate(cases):
|
||||
path = case.get('path', '')
|
||||
try: validate_path(path)
|
||||
except ValueError as error: parser.error(str(error))
|
||||
risky = (case.get('method', 'GET'), path) not in SAFE_CASES or bool(case.get('headers_env')) or bool(case.get('headers_from_cookies')) or args.sessions or (bool(case.get('headers')) and case not in DEFAULT_CASES)
|
||||
# User case labels can contain credentials too. Output stable numeric IDs only.
|
||||
if (risky or case.get('side_effects', False)) and not args.allow_side_effects:
|
||||
print(json.dumps({'case': index, 'result': 'skipped_requires_allow_side_effects'})); continue
|
||||
responses = []
|
||||
for side, base in (('reference', args.reference), ('candidate', args.candidate)):
|
||||
try:
|
||||
resolved = resolve(copy.deepcopy(case), variables[side])
|
||||
validate_path(resolved['path'])
|
||||
response = fetch(base, resolved, side, args.timeout,
|
||||
openers[side] if args.sessions else None,
|
||||
cookies[side] if args.sessions else None)
|
||||
if 'error' not in response:
|
||||
capture_values(response, case.get('capture_json', {}), variables[side])
|
||||
except (KeyError, ValueError, TypeError, IndexError):
|
||||
response = {'error': 'invalid_or_missing_capture'}
|
||||
responses.append(response)
|
||||
left, right = responses
|
||||
errors = [r.get('error') for r in (left, right)]
|
||||
if any(errors):
|
||||
failed = True
|
||||
print(json.dumps({'case': index, 'result': 'error', 'errors': errors})); continue
|
||||
checks = {
|
||||
'status': left['status'] == right['status'],
|
||||
'content_type': left['content_type'] == right['content_type'],
|
||||
'body': comparable(left, args.reference, case) == comparable(right, args.candidate, case),
|
||||
}
|
||||
if case.get('expect_text_token'):
|
||||
checks['text_token_shape'] = all(is_text_token(response) for response in (left, right))
|
||||
if case.get('expected_json') or case.get('absent_json_pointers'):
|
||||
checks['json_assertions'] = all(assert_json_response(response, case) for response in (left, right))
|
||||
if 'expected_status' in case:
|
||||
checks['expected_status'] = left['status'] == right['status'] == case['expected_status']
|
||||
if 'expected_headers' in case:
|
||||
checks['expected_headers'] = all(response['headers'].get(header) == expected for response in (left, right) for header, expected in case['expected_headers'].items())
|
||||
for header in case.get('compare_headers', []):
|
||||
# Headers are compared in memory; neither names nor values are printed.
|
||||
checks['selected_headers'] = checks.get('selected_headers', True) and left['headers'].get(header) == right['headers'].get(header)
|
||||
equal = all(checks.values()); failed |= not equal
|
||||
print(json.dumps({'case': index, 'result': 'equal' if equal else 'different', 'reference_status': left['status'], 'candidate_status': right['status'], 'checks': checks}))
|
||||
return int(failed)
|
||||
if __name__ == '__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract source evidence, not a substitute for Laravel route:list / migrated schema."""
|
||||
import argparse, hashlib, json, pathlib, re, subprocess
|
||||
|
||||
def quoted(s): return re.findall(r"'([^']*)'",s)
|
||||
def middleware(s):
|
||||
m=re.search(r"(?:'middleware'\s*=>|middleware\()\s*(\[[^]]*\]|'[^']*')",s)
|
||||
return quoted(m[1]) if m else []
|
||||
def routes(root,rel,prefix,base):
|
||||
lines=(root/rel).read_text().splitlines(); stack=[]; result=[]; i=0
|
||||
while i<len(lines):
|
||||
s=lines[i].strip(); line=i+1
|
||||
if s.startswith('//'): i+=1; continue
|
||||
if s.startswith('Route::group(') or (s.startswith('Route::middleware(') and '->group(' in s):
|
||||
stack.append(middleware(s)); i+=1; continue
|
||||
if s=='});' and stack: stack.pop(); i+=1; continue
|
||||
if s.startswith('Route::withoutMiddleware('):
|
||||
while '->get(' not in s: i+=1; s+=' '+lines[i].strip()
|
||||
# /up's excluded middleware are retained separately below.
|
||||
if re.match(r'Route::(?:get|post|put|patch|delete|apiResource|withoutMiddleware)',s):
|
||||
while ';' not in s: i+=1; s+=' '+lines[i].strip()
|
||||
m=re.search(r"(?:Route::|->)(get|post|put|patch|delete|apiResource)\('([^']*)'",s)
|
||||
if not m: raise ValueError(f'unparsed {rel}:{line}')
|
||||
verb,path=m.groups(); inherited=base+sum(stack,[]); local=middleware(s) if verb!='get' or 'withoutMiddleware' not in s else []
|
||||
handler=re.search(r'\[([A-Za-z]+)::class,\s*\'([^\']+)\'\]',s)
|
||||
controller=re.search(r"apiResource\('[^']+',\s*(\w+)::class",s)
|
||||
name=re.search(r"->name\('([^']+)'\)",s)
|
||||
constraints=dict(re.findall(r"->where\('([^']+)',\s*'([^']+)'\)",s))
|
||||
variants=[(verb.upper(),path,handler[1]+'@'+handler[2] if handler else 'closure',name[1] if name else None)]
|
||||
if verb=='apiResource':
|
||||
singular={'twofaccounts':'twofaccount','groups':'group','users':'user'}[path]
|
||||
variants=[(v,path+suffix,controller[1]+'@'+action,path+'.'+action) for v,suffix,action in [('GET','','index'),('POST','','store'),('GET','/{'+singular+'}','show'),('PUT','/{'+singular+'}','update'),('PATCH','/{'+singular+'}','update'),('DELETE','/{'+singular+'}','destroy')] if not (path=='users' and action=='update')]
|
||||
for method,p,h,n in variants:
|
||||
entry=dict(method=method,path=prefix+'/'+p.lstrip('/'),handler=h,name=n,middleware_declared=inherited+local,constraints=constraints,source=f'{rel}:{line}')
|
||||
if p=='/up': entry['excluded_middleware']=['StartSession','PreventRequestForgery','SubstituteBindings','SetLanguage','CustomCreateFreshApiToken']
|
||||
if method=='GET': entry['implicit_HEAD']=True
|
||||
result.append(entry)
|
||||
i+=1
|
||||
return result
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(); p.add_argument('--upstream',type=pathlib.Path,default=pathlib.Path('upstream')); p.add_argument('--out',type=pathlib.Path,default=pathlib.Path('rust-server/compatibility')); a=p.parse_args(); root=a.upstream; a.out.mkdir(parents=True,exist_ok=True)
|
||||
if (root/'.git').exists():
|
||||
commit=subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip()
|
||||
else:
|
||||
commit=(root/'UPSTREAM_COMMIT').read_text().strip()
|
||||
if not re.fullmatch(r'[0-9a-f]{40}',commit): raise ValueError('invalid pinned commit metadata')
|
||||
kernel=(root/'app/Http/Kernel.php').read_text()
|
||||
def raw_block(name):
|
||||
m=re.search(r'protected \$'+name+r'\s*=\s*\[(.*?)\n \];',kernel,re.S)
|
||||
return m[1].strip() if m else None
|
||||
rt=routes(root,'routes/web.php','',['web'])+routes(root,'routes/api/v1.php','/api/v1',['api.v1'])
|
||||
payload={'upstream_commit':commit,'kind':'static application declarations; not runtime package routes','notes':['GET routes implicitly support HEAD. Ordering is source ordering.','Middleware declarations require alias/group expansion, deduplication and Laravel priority sorting.','Package-discovered routes and OPTIONS/CORS behavior require runtime route:list.','settingName has global constraint [a-zA-Z]+ from RouteServiceProvider.'], 'middleware_source':'app/Http/Kernel.php','middleware_global_php':raw_block('middleware'),'middleware_groups_php':raw_block('middlewareGroups'),'middleware_aliases_php':raw_block('middlewareAliases'),'middleware_priority_php':raw_block('middlewarePriority'),'routes':rt}
|
||||
write=lambda name,data:(a.out/name).write_text(json.dumps(data,indent=2)+'\n')
|
||||
write('routes.json',payload)
|
||||
migrations=[]
|
||||
for f in sorted((root/'database/migrations').glob('*.php')):
|
||||
src=f.read_text(); up=re.split(r'public function down\(',src)[0]
|
||||
migrations.append({'file':str(f.relative_to(root)),'sha256':hashlib.sha256(src.encode()).hexdigest(),'schema_operations':re.findall(r"Schema::(create|table|rename|dropIfExists)\(\s*'([^']+)'",up),'up_source':up,'note':'Raw up source includes exact definitions and transformations. Helpers defined after down may need the full source; no SQL schema is inferred.'})
|
||||
write('database.json',{'upstream_commit':commit,'kind':'ordered migration evidence; not a materialized SQL schema','drivers':['sqlite','mysql','pgsql','sqlsrv'],'migrations':migrations})
|
||||
env={}
|
||||
for f in sorted((root/'config').glob('*.php')):
|
||||
for n,line in enumerate(f.read_text().splitlines(),1):
|
||||
if line.lstrip().startswith('//'): continue
|
||||
for key in re.findall(r"env\('([^']+)'",line): env.setdefault(key,[]).append(f'{f.relative_to(root)}:{n}')
|
||||
write('environment.json',{'upstream_commit':commit,'kind':'configuration env names and source references only; no deployment values','variables':env,'dynamic_names':['USERPREF_DEFAULT__<PREFERENCE>','USERPREF_LOCKED__<PREFERENCE>']})
|
||||
print(json.dumps({'commit':commit,'application_routes':len(rt),'migrations':len(migrations),'env_names':len(env)}))
|
||||
if __name__=='__main__': main()
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise Passport wire compatibility against a disposable copy of a PHP DB.
|
||||
|
||||
Requires a bootable pinned PHP reference app (including vendor dependencies),
|
||||
its matching APP_KEY/RSA keys, and a freshly built Rust executable. Never changes
|
||||
its source database. Cookies, keys and bearer values are never printed.
|
||||
"""
|
||||
import argparse
|
||||
import http.cookies
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
FIXTURE = r'''<?php
|
||||
require getenv('INTEROP_UPSTREAM').'/vendor/autoload.php';
|
||||
$app=require getenv('INTEROP_UPSTREAM').'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$clients=app(Laravel\Passport\ClientRepository::class);
|
||||
try {$clients->personalAccessClient('users');} catch (RuntimeException $e) {$clients->createPersonalAccessGrantClient('2FAuth','users');}
|
||||
$user=App\Models\User::findOrFail((int)getenv('INTEROP_USER_ID'));
|
||||
// A synthetic fresh login also refreshes the application's inactivity clock.
|
||||
$user->last_seen_at=now(); $user->save();
|
||||
$token=$user->createToken('php-generated-interop', []);
|
||||
$e=app('encrypter'); $csrf='php-interop-csrf-value-00001';
|
||||
$jwt=app(Laravel\Passport\ApiTokenCookieFactory::class)->make($user->id,$csrf)->getValue();
|
||||
$cookie=$e->encryptString(Illuminate\Cookie\CookieValuePrefix::create('laravel_token',$e->getKey()).$jwt);
|
||||
$xsrf=$e->encryptString(Illuminate\Cookie\CookieValuePrefix::create('XSRF-TOKEN',$e->getKey()).$csrf);
|
||||
file_put_contents(getenv('INTEROP_FIXTURE'),json_encode(['token'=>$token->accessToken,'id'=>$token->accessTokenId,'cookie'=>$cookie,'xsrf'=>$xsrf]));
|
||||
'''
|
||||
VALIDATE = r'''<?php
|
||||
require getenv('INTEROP_UPSTREAM').'/vendor/autoload.php';
|
||||
$app=require getenv('INTEROP_UPSTREAM').'/bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
$token=json_decode(file_get_contents(getenv('INTEROP_ISSUED')),true)['accessToken'];
|
||||
$request=Symfony\Component\HttpFoundation\Request::create('/api/v1/twofaccounts','GET',[],[],[],['HTTP_AUTHORIZATION'=>'Bearer '.$token]);
|
||||
$psr=(new Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory)->createRequest($request);
|
||||
$validated=app(League\OAuth2\Server\ResourceServer::class)->validateAuthenticatedRequest($psr);
|
||||
echo json_encode(['php_validated_rust_bearer'=>(string)$validated->getAttribute('oauth_user_id')===getenv('INTEROP_USER_ID')]);
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--php', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--upstream', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--reference-app', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--reference-db', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--candidate-binary', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--user-id', default='1')
|
||||
parser.add_argument('--port', default=18081, type=int)
|
||||
args = parser.parse_args()
|
||||
if not os.environ.get('APP_KEY'):
|
||||
parser.error('APP_KEY must be set to the reference app key')
|
||||
with tempfile.TemporaryDirectory(prefix='twofauth-passport-interop-') as directory:
|
||||
work = pathlib.Path(directory)
|
||||
database = work / 'copy.sqlite'
|
||||
with sqlite3.connect(args.reference_db) as source, sqlite3.connect(database) as target:
|
||||
source.backup(target)
|
||||
target.executescript('CREATE TABLE IF NOT EXISTS rust_sessions (token_hash TEXT PRIMARY KEY,user_id INTEGER,csrf_hash TEXT NOT NULL,expires_at INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS rust_login_attempts (key TEXT PRIMARY KEY,window INTEGER NOT NULL,attempts INTEGER NOT NULL);')
|
||||
fixture_path, issued_path = work / 'fixture.json', work / 'issued.json'
|
||||
fixture_php, validate_php = work / 'fixture.php', work / 'validate.php'
|
||||
fixture_php.write_text(FIXTURE)
|
||||
validate_php.write_text(VALIDATE)
|
||||
php_env = dict(os.environ, APP_BASE_PATH=str(args.reference_app.resolve()), APP_ENV='local', DB_CONNECTION='sqlite', DB_DATABASE=str(database), INTEROP_UPSTREAM=str(args.upstream.resolve()), INTEROP_USER_ID=args.user_id, INTEROP_FIXTURE=str(fixture_path), INTEROP_ISSUED=str(issued_path))
|
||||
php_env.pop('DATABASE_URL', None)
|
||||
subprocess.run([str(args.php.resolve()), str(fixture_php)], env=php_env, check=True, capture_output=True)
|
||||
fixture = json.loads(fixture_path.read_text())
|
||||
rust_env = dict(os.environ, DATABASE_URL='sqlite://' + str(database), PASSPORT_KEY_PATH=str(args.reference_app.resolve() / 'storage'), RUST_COOKIE_SECURE='false')
|
||||
with (work / 'server.log').open('w') as log:
|
||||
process = subprocess.Popen([str(args.candidate_binary.resolve()), 'serve', '--listen', f'127.0.0.1:{args.port}'], env=rust_env, stdout=log, stderr=subprocess.STDOUT)
|
||||
base = f'http://127.0.0.1:{args.port}'
|
||||
|
||||
def request(path='/api/v1/twofaccounts', headers=None, method='GET', body=None, cookies=None):
|
||||
headers = dict(headers or {})
|
||||
if cookies is not None:
|
||||
headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
|
||||
data = None if body is None else json.dumps(body).encode()
|
||||
if data is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
req = urllib.request.Request(base + path, headers=headers, method=method, data=data)
|
||||
try:
|
||||
response = urllib.request.urlopen(req, timeout=10)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
if cookies is not None:
|
||||
for header in response.headers.get_all('Set-Cookie', []):
|
||||
parsed = http.cookies.SimpleCookie()
|
||||
parsed.load(header)
|
||||
for name, item in parsed.items():
|
||||
if item['max-age'] == '0' or not item.value:
|
||||
cookies.pop(name, None)
|
||||
else:
|
||||
cookies[name] = item.value
|
||||
return response.status, response.read()
|
||||
|
||||
try:
|
||||
for _ in range(100):
|
||||
try:
|
||||
request('/up')
|
||||
break
|
||||
except OSError:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError('candidate exited before readiness')
|
||||
time.sleep(.05)
|
||||
results = {
|
||||
'php_bearer': request(headers={'Authorization': 'Bearer ' + fixture['token']})[0],
|
||||
'php_cookie': request(headers={'Cookie': 'laravel_token=' + fixture['cookie'], 'X-XSRF-TOKEN': fixture['xsrf']})[0],
|
||||
'cookie_missing_csrf': request(headers={'Cookie': 'laravel_token=' + fixture['cookie']})[0],
|
||||
'tampered_bearer': request(headers={'Authorization': 'Bearer ' + fixture['token'][:-8] + 'AAAAAAAA'})[0],
|
||||
}
|
||||
cookies = {'laravel_token': fixture['cookie'], 'XSRF-TOKEN': fixture['xsrf']}
|
||||
results['php_browser_bootstrap'] = request('/', cookies=cookies)[0]
|
||||
results['legacy_cookie_cleared'] = 'laravel_token' not in cookies
|
||||
results['upgraded_browser_api'] = request(cookies=cookies, headers={'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN', '')})[0]
|
||||
results['upgraded_browser_logout'] = request('/user/logout', cookies=cookies, headers={'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN', '')})[0]
|
||||
results['browser_api_after_logout'] = request(cookies=cookies)[0]
|
||||
status, issued = request('/oauth/personal-access-tokens', headers={'Authorization': 'Bearer ' + fixture['token']}, method='POST', body={'name': 'rust-generated-interop'})
|
||||
if status != 200:
|
||||
raise AssertionError(f'Rust PAT issuance returned {status}')
|
||||
issued_path.write_bytes(issued)
|
||||
validated = subprocess.run([str(args.php.resolve()), str(validate_php)], env=php_env, capture_output=True, text=True, check=True)
|
||||
results.update(json.loads(validated.stdout))
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute('UPDATE oauth_access_tokens SET revoked=1 WHERE id=?', (fixture['id'],))
|
||||
results['revoked_php_bearer'] = request(headers={'Authorization': 'Bearer ' + fixture['token']})[0]
|
||||
expected = {'php_bearer': 200, 'php_cookie': 200, 'cookie_missing_csrf': 401, 'tampered_bearer': 401, 'php_browser_bootstrap': 200, 'legacy_cookie_cleared': True, 'upgraded_browser_api': 200, 'upgraded_browser_logout': 200, 'browser_api_after_logout': 401, 'php_validated_rust_bearer': True, 'revoked_php_bearer': 401}
|
||||
print(json.dumps(results, indent=2))
|
||||
if results != expected:
|
||||
raise AssertionError('Passport interoperability expectations failed')
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run HTTP comparisons using fresh, disposable PHP and Rust fixture databases.
|
||||
Requires PHP 8.4+ with required extensions and Composer-installed upstream/vendor.
|
||||
No production configuration is copied. No response bodies or cookies are printed.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def command(argv, env, cwd):
|
||||
result = subprocess.run([str(x) for x in argv], env=env, cwd=cwd, capture_output=True)
|
||||
if result.returncode:
|
||||
raise RuntimeError('fixture_command_failed')
|
||||
return result.stdout.decode(errors='replace')
|
||||
|
||||
|
||||
def port():
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--php', required=True, type=Path)
|
||||
parser.add_argument('--source', type=Path, default=ROOT.parent / 'upstream')
|
||||
parser.add_argument('--candidate-binary', type=Path, default=ROOT / 'target/release/twofauth-rs')
|
||||
parser.add_argument('--output', type=Path)
|
||||
parser.add_argument('--candidate-db', choices=('native', 'laravel-copy'), default='native', help='native init or SQLite backup of real migrated PHP fixture')
|
||||
parser.add_argument('--timeout', type=float, default=30)
|
||||
parser.add_argument('--semantic-checks', action='store_true', help='also verify health timing, CSRF login and rendered QR content; QR needs Node sharp')
|
||||
parser.add_argument('--passport-interop', action='store_true', help='also exercise PHP-issued and Rust-issued Passport tokens against the disposable migrated database')
|
||||
parser.add_argument('--extra-cases', type=Path, action='append', default=[], help='additional stateful JSON case files to compare using the disposable fixtures')
|
||||
args = parser.parse_args()
|
||||
php, source, binary = (p.resolve() for p in (args.php, args.source, args.candidate_binary))
|
||||
if not (source / 'vendor/autoload.php').is_file():
|
||||
parser.error('install the pinned upstream Composer dependencies first')
|
||||
processes = []
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='twofauth-reference-') as directory:
|
||||
work = Path(directory)
|
||||
candidate_source = str(binary)
|
||||
shutil.copy2(binary, work / 'candidate-binary')
|
||||
binary = work / 'candidate-binary'
|
||||
app = work / 'reference'
|
||||
shutil.copytree(source, app, ignore=shutil.ignore_patterns('vendor', 'node_modules', '.env*', 'cache', 'logs', 'sessions', 'testing'))
|
||||
(app / 'vendor').symlink_to(source / 'vendor', target_is_directory=True)
|
||||
for subdir in ('bootstrap/cache', 'storage/logs', 'storage/framework/cache/data', 'storage/framework/sessions', 'storage/framework/views'):
|
||||
(app / subdir).mkdir(parents=True, exist_ok=True)
|
||||
reference_port, candidate_port = port(), port()
|
||||
while candidate_port == reference_port: candidate_port = port()
|
||||
reference_base = f'http://127.0.0.1:{reference_port}'
|
||||
candidate_base = f'http://127.0.0.1:{candidate_port}'
|
||||
# Start with an explicit environment so deployment secrets/config cannot leak into fixtures.
|
||||
runtime = {name: os.environ[name] for name in ('PATH', 'PHPRC', 'PHP_INI_SCAN_DIR', 'LD_LIBRARY_PATH', 'SYSTEMROOT') if name in os.environ}
|
||||
reference_env = dict(runtime, APP_BASE_PATH=str(app), APP_ENV='local', APP_NAME='2FAuth', APP_KEY='base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', APP_URL=reference_base, DB_CONNECTION='sqlite', DB_DATABASE=str(work / 'reference.sqlite'), SESSION_DRIVER='file', SESSION_SECURE_COOKIE='false', CACHE_STORE='file', MAIL_MAILER='array', THROTTLE_API='1000')
|
||||
candidate_env = dict(runtime, APP_URL=candidate_base, APP_KEY=reference_env['APP_KEY'], THROTTLE_API='1000', DATABASE_URL='sqlite://' + str(work / 'candidate.sqlite'), RUST_COOKIE_SECURE='false', RUST_PUBLIC_DIR=str(ROOT / 'public'))
|
||||
php_version = command([php, '-r', 'echo PHP_VERSION;'], reference_env, app)
|
||||
command([php, 'artisan', 'migrate', '--force'], reference_env, app)
|
||||
command([php, 'artisan', 'passport:keys', '--force'], reference_env, app)
|
||||
fixture = work / 'fixture.php'
|
||||
fixture.write_text('''<?php
|
||||
require getenv('APP_BASE_PATH').'/vendor/autoload.php';
|
||||
$app = require getenv('APP_BASE_PATH').'/bootstrap/app.php';
|
||||
$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
|
||||
foreach ([['Compat','compat@example.invalid',false],['Admin','admin@example.invalid',true],['Recipient','recipient@example.invalid',false]] as [$name,$email,$admin]) {
|
||||
$user = new App\\Models\\User;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->password = 'Synthetic-compat-password-4620';
|
||||
$user->is_admin = $admin;
|
||||
$user->save();
|
||||
}
|
||||
''')
|
||||
command([php, fixture], reference_env, app)
|
||||
if args.candidate_db == 'laravel-copy':
|
||||
with sqlite3.connect(work / 'reference.sqlite') as reference_db, sqlite3.connect(work / 'candidate.sqlite') as candidate_db:
|
||||
reference_db.backup(candidate_db)
|
||||
candidate_env['APP_KEY'] = reference_env['APP_KEY']
|
||||
command([binary, 'prepare-sqlite'], candidate_env, work)
|
||||
else:
|
||||
command([binary, 'init'], candidate_env, work)
|
||||
password = work / 'password'
|
||||
password.write_text('Synthetic-compat-password-4620')
|
||||
password.chmod(0o600)
|
||||
for name, email in (('Compat', 'compat@example.invalid'), ('Admin', 'admin@example.invalid'), ('Recipient', 'recipient@example.invalid')):
|
||||
command([binary, 'create-user', '--name', name, '--email', email, '--password-file', password], candidate_env, work)
|
||||
with sqlite3.connect(work / 'candidate.sqlite') as database:
|
||||
database.execute("UPDATE users SET is_admin=1 WHERE email='admin@example.invalid'")
|
||||
password.unlink()
|
||||
commands = [([php, '-S', f'127.0.0.1:{reference_port}', '-t', app / 'public', source / 'vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php'], reference_env, app / 'public'), ([binary, 'serve', '--listen', f'127.0.0.1:{candidate_port}'], candidate_env, work)]
|
||||
for argv, env, cwd in commands:
|
||||
processes.append(subprocess.Popen([str(x) for x in argv], env=env, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
|
||||
for base, process in zip((reference_base, candidate_base), processes):
|
||||
for _ in range(100):
|
||||
if process.poll() is not None: raise RuntimeError('server_start_failed')
|
||||
try:
|
||||
with urllib.request.urlopen(base + '/up', timeout=1): break
|
||||
except Exception: time.sleep(.05)
|
||||
else: raise RuntimeError('server_start_timeout')
|
||||
record = {'candidate_source': candidate_source, 'candidate_database_mode': args.candidate_db, 'reference_php': php_version, 'candidate_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'database': 'fresh disposable SQLite; upstream migrations applied', 'fixture_config': {'THROTTLE_API': 1000, 'note': 'bulk fixture sequence; does not establish default throttle equivalence'}, 'composer_lock_sha256': hashlib.sha256((source / 'composer.lock').read_bytes()).hexdigest(), 'harness_sha256': hashlib.sha256((ROOT / 'tools/differential_http.py').read_bytes()).hexdigest(), 'results': {}}
|
||||
suites = [('default', None)] + [(name, ROOT / f'compatibility/cases.{name}.json') for name in ('stateful', 'extended', 'admin-sharing')]
|
||||
def compare_suite(name, cases_path):
|
||||
argv = [sys.executable, str(ROOT / 'tools/differential_http.py'), '--reference', reference_base, '--candidate', candidate_base, '--timeout', str(args.timeout)]
|
||||
if cases_path is not None: argv += ['--cases', str(cases_path), '--sessions', '--allow-side-effects']
|
||||
result = subprocess.run(argv, capture_output=True, text=True)
|
||||
if result.returncode not in (0, 1): raise RuntimeError('comparison_failed')
|
||||
record['results'][name] = {'exit_code': result.returncode, 'cases': [json.loads(line) for line in result.stdout.splitlines()]}
|
||||
if cases_path is not None: record['results'][name]['cases_sha256'] = hashlib.sha256(cases_path.read_bytes()).hexdigest()
|
||||
for name, cases_path in suites:
|
||||
compare_suite(name, cases_path)
|
||||
if args.output: args.output.write_text(json.dumps(record, indent=2) + '\n')
|
||||
if args.semantic_checks:
|
||||
spec = importlib.util.spec_from_file_location('semantic_http', ROOT / 'tools/semantic_http.py')
|
||||
semantic = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(semantic)
|
||||
record['semantic_results'] = semantic.check(reference_base, candidate_base, php, source, work, reference_env, args.timeout)
|
||||
checks = record['semantic_results']
|
||||
record['semantic_pass'] = all(checks['health_render_timing'].values()) and all(checks['csrf_token_flow'].values()) and checks['qr_payload'].get('result') == 'decoded' and checks['qr_payload'].get('decoded_uri_equal') and checks['qr_payload'].get('matches_fixture')
|
||||
# OAuth uses Laravel's unnamed web throttle, shared with password login.
|
||||
# Run login/QR semantics before the extra token-error burst consumes it.
|
||||
record['extra_cases_order'] = 'after core suites and supplemental semantic checks'
|
||||
for index, path in enumerate(args.extra_cases):
|
||||
compare_suite('extra-' + str(index), path.resolve())
|
||||
if args.passport_interop:
|
||||
interop_env = dict(reference_env, RUST_PUBLIC_DIR=str(ROOT / 'public'))
|
||||
interop = subprocess.run([sys.executable, str(ROOT / 'tools/passport_interop.py'), '--php', str(php), '--upstream', str(source), '--reference-app', str(app), '--reference-db', str(work / 'reference.sqlite'), '--candidate-binary', str(binary), '--port', str(port())], env=interop_env, capture_output=True, text=True)
|
||||
record['passport_interop'] = {'exit_code': interop.returncode}
|
||||
if interop.returncode == 0:
|
||||
record['passport_interop']['checks'] = json.loads(interop.stdout)
|
||||
# Stop the servers before their temporary application files are removed.
|
||||
for process in processes:
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
serialized = json.dumps(record, indent=2) + '\n'
|
||||
if args.output: args.output.write_text(serialized)
|
||||
print(serialized, end='')
|
||||
return int(any(result['exit_code'] for result in record['results'].values()) or (args.semantic_checks and not record.get('semantic_pass', False)) or record.get('passport_interop', {}).get('exit_code', 0))
|
||||
except Exception as error:
|
||||
print(json.dumps({'error': type(error).__name__}), file=sys.stderr)
|
||||
return 2
|
||||
finally:
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__': raise SystemExit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Supplemental semantic checks for disposable fixture servers; never print bodies."""
|
||||
import base64
|
||||
import http.cookiejar
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
def check(reference, candidate, php, source, work, php_env, timeout):
|
||||
spec = importlib.util.spec_from_file_location('compat_http', Path(__file__).with_name('differential_http.py'))
|
||||
harness = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(harness)
|
||||
sides = {}
|
||||
for side, base in (('reference', reference), ('candidate', candidate)):
|
||||
jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(harness.NoRedirect(), urllib.request.HTTPCookieProcessor(jar))
|
||||
def fetch(case): return harness.fetch(base, case, side, timeout, opener, jar)
|
||||
health = fetch({'path': '/up'})
|
||||
csrf = fetch({'path': '/refresh-csrf'})
|
||||
headers = {'X-XSRF-TOKEN': 'XSRF-TOKEN'}
|
||||
login = fetch({'path': '/user/login', 'method': 'POST', 'headers_from_cookies': headers, 'json': {'email': 'compat@example.invalid', 'password': 'Synthetic-compat-password-4620'}})
|
||||
created = fetch({'path': '/api/v1/twofaccounts', 'method': 'POST', 'headers_from_cookies': headers, 'json': {'service': 'compat-semantic', 'account': 'fixture@example.invalid', 'otp_type': 'hotp', 'secret': 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 'digits': 6, 'algorithm': 'sha1', 'counter': 0}})
|
||||
qr = {'error': 'account_not_created'}
|
||||
if created.get('status') == 201:
|
||||
account_id = json.loads(created['body'])['id']
|
||||
qr = fetch({'path': f'/api/v1/twofaccounts/{account_id}/qrcode', 'headers_from_cookies': headers})
|
||||
sides[side] = {'base': base, 'health': health, 'csrf': csrf, 'login': login, 'qr': qr}
|
||||
pattern = r'Response successfully rendered in [0-9]+ms\.'
|
||||
health_bodies = [sides[side]['health'].get('body', b'').decode(errors='replace').replace(sides[side]['base'], '<BASE>') for side in sides]
|
||||
health = {'content_types_match': all(sides[side]['health'].get('content_type') == 'text/html' for side in sides), 'timing_field_present_once': all(len(re.findall(pattern, body)) == 1 for body in health_bodies), 'equal_except_render_ms': re.sub(pattern, 'Response successfully rendered in <MS>ms.', health_bodies[0]) == re.sub(pattern, 'Response successfully rendered in <MS>ms.', health_bodies[1]), 'statuses_ok': all(sides[side]['health'].get('status') == 200 for side in sides)}
|
||||
csrf = {'text_token_shapes_valid': all(harness.is_text_token(sides[side]['csrf']) for side in sides), 'subsequent_login_succeeded': all(sides[side]['login'].get('status') == 200 for side in sides)}
|
||||
qr = {'result': 'error', 'decoded_uri_equal': False, 'matches_fixture': False}
|
||||
try:
|
||||
node = os.environ.get('CODEX_PRIMARY_RUNTIME_NODE') or shutil.which('node')
|
||||
if not node: raise RuntimeError('missing_svg_renderer')
|
||||
decoded = []
|
||||
for side in sides:
|
||||
response = sides[side]['qr']
|
||||
if response.get('status') != 200 or response.get('content_type') != 'application/json': raise RuntimeError('qr_request_failed')
|
||||
image = json.loads(response['body'])['qrcode']
|
||||
prefix = 'data:image/svg+xml;base64,'
|
||||
if not image.startswith(prefix): raise RuntimeError('unexpected_image_format')
|
||||
svg = work / (side + '-qr.svg')
|
||||
png = work / (side + '-qr.png')
|
||||
svg.write_bytes(base64.b64decode(image[len(prefix):], validate=True))
|
||||
render = subprocess.run([node, '-e', "require('sharp')(process.argv[1],{density:300}).resize(1024,1024).flatten({background:'#fff'}).png().toFile(process.argv[2]).catch(()=>process.exit(1))", str(svg), str(png)], capture_output=True, timeout=30)
|
||||
if render.returncode: raise RuntimeError('svg_render_failed')
|
||||
code = "require $argv[1]; $r=new Zxing\\QrReader($argv[2]); $t=$r->text(); if (!is_string($t)||$t==='') exit(1); echo $t;"
|
||||
result = subprocess.run([str(php), '-r', code, str(source / 'vendor/autoload.php'), str(png)], env=php_env, capture_output=True, timeout=30)
|
||||
if result.returncode: raise RuntimeError('qr_decode_failed')
|
||||
uri = result.stdout.decode()
|
||||
parsed = urllib.parse.urlsplit(uri)
|
||||
values = dict(urllib.parse.parse_qsl(parsed.query))
|
||||
normalized = {'type': parsed.netloc.lower(), 'label': urllib.parse.unquote(parsed.path.lstrip('/')), 'secret': values.get('secret'), 'issuer': values.get('issuer'), 'algorithm': values.get('algorithm', 'SHA1').lower(), 'digits': int(values.get('digits', 6)), 'counter': int(values.get('counter', 0))}
|
||||
if parsed.scheme.lower() != 'otpauth': raise RuntimeError('unexpected_qr_payload')
|
||||
decoded.append(normalized)
|
||||
expected = {'type': 'hotp', 'label': 'compat-semantic:fixture@example.invalid', 'secret': 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 'issuer': 'compat-semantic', 'algorithm': 'sha1', 'digits': 6, 'counter': 0}
|
||||
qr = {'result': 'decoded', 'decoded_uri_equal': decoded[0] == decoded[1], 'matches_fixture': all(value == expected for value in decoded)}
|
||||
except Exception as error:
|
||||
qr['error'] = type(error).__name__
|
||||
if isinstance(error, RuntimeError): qr['reason'] = str(error)
|
||||
return {'health_render_timing': health, 'csrf_token_flow': csrf, 'qr_payload': qr}
|
||||
Reference in new issue
Block a user