| 1 | #!/usr/bin/env python3 |
| 2 | """Exercise native OIDC over HTTP with independently verified RSA signatures.""" |
| 3 | import argparse |
| 4 | import base64 |
| 5 | import hashlib |
| 6 | import http.client |
| 7 | import json |
| 8 | import os |
| 9 | from pathlib import Path |
| 10 | import socket |
| 11 | import sqlite3 |
| 12 | import subprocess |
| 13 | import tempfile |
| 14 | import time |
| 15 | import urllib.parse |
| 16 | import uuid |
| 17 | from cryptography.hazmat.primitives import hashes |
| 18 | from cryptography.hazmat.primitives.asymmetric import rsa, padding |
| 19 | from cryptography.hazmat.primitives.kdf.argon2 import Argon2id |
| 20 | |
| 21 | |
| 22 | def b64(value): |
| 23 | return base64.urlsafe_b64encode(value).decode().rstrip('=') |
| 24 | |
| 25 | |
| 26 | def main(): |
| 27 | parser = argparse.ArgumentParser() |
| 28 | parser.add_argument('--binary', type=Path, default=Path('dashboard/target/debug/home-dashboard')) |
| 29 | args = parser.parse_args() |
| 30 | origin = 'https://snowglobe.paperclover.net' |
| 31 | callback = 'https://shale.paperclover.net/-/callback' |
| 32 | actor, role = str(uuid.uuid4()), str(uuid.uuid4()) |
| 33 | password, secret, proof = uuid.uuid4().hex, uuid.uuid4().hex, uuid.uuid4().hex + uuid.uuid4().hex |
| 34 | salt = os.urandom(16) |
| 35 | digest = Argon2id(salt=salt, length=32, iterations=5, lanes=1, memory_cost=7168).derive(password.encode()) |
| 36 | export = {'rpId': 'auth.paperclover.net', 'roles': [{'id': role, 'name': 'infra-admin'}], 'users': [{ |
| 37 | 'id': actor, 'username': 'oidc-test', 'enabled': True, 'email': 'oidc-test@example.invalid', 'emailVerified': True, |
| 38 | 'firstName': 'OIDC', 'lastName': 'Test', 'createdTimestamp': 1, 'requiredActions': [], 'attributes': {}, 'roles': [role], |
| 39 | 'credentials': [{'id': str(uuid.uuid4()), 'type': 'password', 'createdDate': 1, |
| 40 | 'credentialData': {'algorithm': 'argon2', 'hashIterations': 5, 'additionalParameters': {'type': ['id'], 'memory': ['7168'], 'parallelism': ['1']}}, |
| 41 | 'secretData': {'salt': base64.b64encode(salt).decode(), 'value': base64.b64encode(digest).decode()}}]}]} |
| 42 | with tempfile.TemporaryDirectory(prefix='dashboard-oidc-') as temporary: |
| 43 | data = Path(temporary).resolve() |
| 44 | (data / 'proof').write_text(proof) |
| 45 | (data / 'source.json').write_text(json.dumps(export)) |
| 46 | environment = {**os.environ, 'STUDIO_DOMAIN': 'paperclover.net', 'STUDIO_DATA_DIR': str(data), |
| 47 | 'STUDIO_PUBLIC_ORIGIN': origin, 'STUDIO_AUTH_RP_ID': 'auth.paperclover.net', 'STUDIO_FILE_ORIGIN': 'https://file.paperclover.net', |
| 48 | 'STUDIO_WEB_DIR': str(Path('dashboard/dist').resolve()), 'STUDIO_PROXY_TOKEN_FILE': str(data / 'proof'), 'STUDIO_AUTH_REQUIRED': '1'} |
| 49 | binary = str(args.binary.resolve()) |
| 50 | result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) |
| 51 | assert result.returncode == 0, result.stderr |
| 52 | def provision(client, redirects, aliases=None, status=0, guests=False, username=False): |
| 53 | result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True, |
| 54 | input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client, |
| 55 | 'redirectUris': redirects, 'usernameAliases': aliases or {}, 'allowGuests': guests, 'usernameRequired': username}, |
| 56 | 'existing': {'clientId': client, 'clientSecret': secret}})) |
| 57 | assert result.returncode == status, result.stderr |
| 58 | provision('shale', [callback], {'oidc-test': 'clover'}, guests=True, username=True) |
| 59 | provision('other', ['https://jelly.paperclover.net/callback']) |
| 60 | provision('bad', ['https://evil.example/callback'], status=1) |
| 61 | provision('bad', ['https://shale.paperclover.net/*'], status=1) |
| 62 | provision('shale-preview-bad', ['https://jelly.paperclover.net/-/callback'], guests=True, status=1) |
| 63 | with socket.socket() as available: |
| 64 | available.bind(('127.0.0.1', 0)); port = available.getsockname()[1] |
| 65 | environment['PORT'] = str(port) |
| 66 | log = (data / 'server.log').open('wb') |
| 67 | server = None |
| 68 | def start(): |
| 69 | nonlocal server |
| 70 | server = subprocess.Popen([binary], env=environment, stdout=log, stderr=log) |
| 71 | deadline = time.monotonic() + 15 |
| 72 | while True: |
| 73 | try: |
| 74 | with socket.create_connection(('127.0.0.1', port), timeout=.1): break |
| 75 | except OSError: |
| 76 | assert server.poll() is None, (data / 'server.log').read_text()[-1000:] |
| 77 | if time.monotonic() > deadline: raise AssertionError('dashboard did not start') |
| 78 | time.sleep(.05) |
| 79 | def stop(): |
| 80 | server.terminate(); server.wait(timeout=10) |
| 81 | cookies = {} |
| 82 | def request(path, method='GET', body=None, status=200, extra=None, session=True, form=False): |
| 83 | headers = {'Studio-Proxy-Token': proof, 'Host': 'snowglobe.paperclover.net', 'X-Studio-Client-IP': '127.0.0.1'} |
| 84 | if body is not None: headers.update({'Origin': origin, 'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}) |
| 85 | if session: headers['Cookie'] = '; '.join(f'{k}={v}' for k,v in cookies.items()) |
| 86 | headers.update(extra or {}) |
| 87 | connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15) |
| 88 | content = (urllib.parse.urlencode(body) if form else json.dumps(body)) if body is not None else None |
| 89 | connection.request(method, path, body=content, headers=headers) |
| 90 | response = connection.getresponse(); content = response.read(); fields = dict(response.getheaders()); connection.close() |
| 91 | assert response.status == status, (path, response.status, content[:200]) |
| 92 | if session and 'set-cookie' in fields: |
| 93 | key,value = fields['set-cookie'].split(';',1)[0].split('=',1); cookies[key] = value |
| 94 | return json.loads(content) if fields.get('content-type','').startswith('application/json') and content else fields |
| 95 | start() |
| 96 | try: |
| 97 | csrf = request('/auth/status')['csrf'] |
| 98 | login = {'csrf': csrf, 'username': 'oidc-test', 'password': password, 'next': '/'} |
| 99 | request('/auth/password', 'POST', login) |
| 100 | metadata = request('/.well-known/openid-configuration', extra={'Studio-Proxy-Token': 'wrong'}) |
| 101 | assert metadata['issuer'] == origin and metadata['id_token_signing_alg_values_supported'] == ['RS256'] |
| 102 | jwk = request('/auth/oidc/jwks')['keys'][0] |
| 103 | decode = lambda s: base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)) |
| 104 | key = rsa.RSAPublicNumbers(int.from_bytes(decode(jwk['e']), 'big'), int.from_bytes(decode(jwk['n']), 'big')).public_key() |
| 105 | verifier = b64(os.urandom(32)) |
| 106 | authorize = {'client_id': 'shale', 'redirect_uri': callback, 'response_type': 'code', |
| 107 | 'scope': 'openid profile email groups', 'state': 'fixture-state', 'nonce': 'fixture-nonce', |
| 108 | 'code_challenge': b64(hashlib.sha256(verifier.encode()).digest()), 'code_challenge_method': 'S256'} |
| 109 | authorize_path = lambda values: '/auth/oidc/authorize?' + urllib.parse.urlencode(values) |
| 110 | request(authorize_path({**authorize, 'redirect_uri': 'https://evil.example/'}), status=400) |
| 111 | request(authorize_path(authorize) + '&client_id=other', status=400) |
| 112 | request(authorize_path({**authorize, 'scope': 'openid profile unknown'}), status=400) |
| 113 | assert request(authorize_path(authorize), session=False, status=302)['location'].startswith('/sign-in?next=') |
| 114 | denied = request(authorize_path({**authorize, 'prompt': 'none'}), session=False, status=302)['location'] |
| 115 | assert urllib.parse.parse_qs(urllib.parse.urlparse(denied).query)['error'] == ['login_required'] |
| 116 | def code(values=authorize): |
| 117 | location = request(authorize_path(values), status=302)['location'] |
| 118 | result = urllib.parse.parse_qs(urllib.parse.urlparse(location).query) |
| 119 | assert result['state'] == ['fixture-state'] |
| 120 | return result['code'][0] |
| 121 | exchange = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'authorization_code', |
| 122 | 'redirect_uri': callback, 'code': code(), 'code_verifier': verifier} |
| 123 | for change in [{'client_secret': 'wrong'}, {'client_id': 'other'}, {'code_verifier': 'wrong'}, {'redirect_uri': 'https://evil.example/'}]: |
| 124 | request('/auth/oidc/token', 'POST', {**exchange, **change}, status=400, form=True) |
| 125 | other_basic = {'Authorization': 'Basic ' + base64.b64encode(('other:' + secret).encode()).decode()} |
| 126 | assert request('/auth/oidc/token', 'POST', {'client_id':'other','client_secret':secret,'grant_type':'authorization_code'}, extra=other_basic, status=400, form=True)['error'] == 'invalid_request' |
| 127 | basic_header = {'Authorization': 'Basic ' + base64.b64encode(('shale:' + secret).encode()).decode()} |
| 128 | request('/auth/oidc/token', 'POST', {**exchange, 'client_secret': 'different'}, extra=basic_header, status=400, form=True) |
| 129 | tokens = request('/auth/oidc/token', 'POST', exchange, extra=basic_header, form=True) |
| 130 | request('/auth/oidc/token', 'POST', exchange, status=400, form=True) |
| 131 | parts = tokens['id_token'].split('.') |
| 132 | key.verify(decode(parts[2]), (parts[0] + '.' + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256()) |
| 133 | claims = json.loads(decode(parts[1])); assert claims['iss'] == origin and claims['sub'] == actor and claims['aud'] == 'shale' |
| 134 | assert claims['nonce'] == 'fixture-nonce' and claims['preferred_username'] == 'clover' |
| 135 | assert claims['groups'] == ['role:infra-admin'] and claims['email_verified'] is True |
| 136 | assert claims['at_hash'] == b64(hashlib.sha256(tokens['access_token'].encode()).digest()[:16]) |
| 137 | bearer = lambda token: {'Authorization': 'Bearer ' + token} |
| 138 | assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']))['sub'] == actor |
| 139 | refresh = {'client_id': 'shale', 'client_secret': secret, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token']} |
| 140 | rotated = request('/auth/oidc/token', 'POST', refresh, form=True) |
| 141 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) |
| 142 | request('/auth/oidc/token', 'POST', refresh, form=True, status=400) |
| 143 | request('/auth/oidc/userinfo', extra=bearer(rotated['access_token']), status=401) |
| 144 | request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': rotated['refresh_token']}, form=True, status=400) |
| 145 | # Shale uses a confidential client without PKCE; basic client auth also works. |
| 146 | unbound = {k:v for k,v in authorize.items() if not k.startswith('code_challenge')} |
| 147 | basic = {'Authorization': 'Basic ' + base64.b64encode(('shale:' + secret).encode()).decode()} |
| 148 | tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(unbound), 'redirect_uri': callback}, extra=basic, form=True) |
| 149 | request('/auth/oidc/revoke', 'POST', {'token': tokens['refresh_token']}, extra=basic, form=True) |
| 150 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) |
| 151 | # Reauthentication must use a new session, even when a caller spoofs the continuation. |
| 152 | forced = request(authorize_path({**unbound, 'prompt': 'login', 'snow_reauth': '1'}), status=302)['location'] |
| 153 | continuation = urllib.parse.parse_qs(urllib.parse.urlparse(forced).query)['next'][0] |
| 154 | assert request(continuation, status=302)['location'].startswith('/sign-in?next=') |
| 155 | request('/auth/password', 'POST', login) |
| 156 | resumed = request(continuation, status=302)['location'] |
| 157 | assert 'code=' in resumed and resumed.startswith(callback) |
| 158 | tokens = request('/auth/oidc/token', 'POST', {**exchange, 'code': code()}, form=True) |
| 159 | # Account disabling is checked at the token endpoint, not only at sign-in. |
| 160 | disabled_code = code() |
| 161 | db = sqlite3.connect(data / 'accounts.sqlite') |
| 162 | db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?", (actor,)); db.commit() |
| 163 | request('/auth/oidc/token', 'POST', {**exchange, 'code': disabled_code}, status=400, form=True) |
| 164 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) |
| 165 | db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('true')) WHERE id=?", (actor,)); db.commit(); db.close() |
| 166 | stop(); start() |
| 167 | assert request('/auth/oidc/jwks')['keys'][0] == jwk |
| 168 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token'])) |
| 169 | request('/auth/sign-out', 'POST', {}) |
| 170 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) |
| 171 | request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400) |
| 172 | # The real Shale client requests only openid; its registered mapping still supplies a username. |
| 173 | request('/auth/password', 'POST', login) |
| 174 | minimal = {**unbound, 'scope': 'openid'} |
| 175 | tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(minimal), 'redirect_uri': callback}, extra=basic, form=True) |
| 176 | assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token'])) == {'sub': actor, 'preferred_username': 'clover'} |
| 177 | request('/auth/sign-out', 'POST', {}) |
| 178 | # Provider UI is offered only for an exact registered Shale authorization request. |
| 179 | configured = subprocess.run([binary, '--guest-provider'], env=environment, capture_output=True, text=True, |
| 180 | input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret})) |
| 181 | assert configured.returncode == 0, configured.stderr |
| 182 | target = authorize_path(minimal) |
| 183 | assert request('/auth/status')['service'] == 'snow globe' |
| 184 | assert request('/auth/status')['providers'] == [] |
| 185 | assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['service'] == 'shale' |
| 186 | forged = target.replace(urllib.parse.quote(callback, safe=''), urllib.parse.quote('https://evil.example/callback', safe='')) |
| 187 | assert request('/auth/status?' + urllib.parse.urlencode({'next': forged}))['service'] == 'snow globe' |
| 188 | assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}] |
| 189 | request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400) |
| 190 | started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302) |
| 191 | external = urllib.parse.urlparse(started['location']); parameters = urllib.parse.parse_qs(external.query) |
| 192 | assert external.netloc == 'github.com' and parameters['scope'] == ['read:user'] and parameters['code_challenge_method'] == ['S256'] |
| 193 | state = parameters['state'][0] |
| 194 | request('/auth/guest/callback/github?state=' + state, session=False, status=403) |
| 195 | request('/auth/guest/callback/astheno?state=' + state, status=503) |
| 196 | declined = request('/auth/guest/callback/github?' + urllib.parse.urlencode({'state':state,'error':'access_denied'}), status=302)['location'] |
| 197 | assert 'guest_error=1' in declined |
| 198 | request('/auth/guest/callback/github?state=' + state, status=403) |
| 199 | # A guest's SSO cookie cannot open dashboard APIs, Files, credentials, or other OIDC clients. |
| 200 | guest, session_token = str(uuid.uuid4()), b64(os.urandom(32)) |
| 201 | db = sqlite3.connect(data / 'accounts.sqlite') |
| 202 | profile = {'kind':'guest','guestProvider':'github','username':'guest-github-123','enabled':True,'email':None,'emailVerified':False,'firstName':'clover','lastName':None,'requiredActions':[]} |
| 203 | db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (guest,json.dumps(profile))) |
| 204 | stamp = int(time.time()); session_hash = hashlib.sha256(session_token.encode()).hexdigest() |
| 205 | db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',(session_hash,guest,'dashboard',stamp+3600,'127.0.0.1',stamp,stamp,stamp)); db.commit() |
| 206 | cookies['__Host-snow-session'] = session_token |
| 207 | guest_csrf = request('/auth/status')['csrf'] |
| 208 | request('/api/me', status=403) |
| 209 | assert request('/', status=302)['location'] == 'https://shale.paperclover.net/' |
| 210 | request('/auth/file/check', status=401) |
| 211 | request('/auth/passkey/register','POST',{'csrf':guest_csrf},status=403) |
| 212 | request(authorize_path({**unbound, 'client_id':'other','redirect_uri':'https://jelly.paperclover.net/callback'}), status=403) |
| 213 | tokens = request('/auth/oidc/token','POST',{'grant_type':'authorization_code','code':code(minimal),'redirect_uri':callback},extra=basic,form=True) |
| 214 | identity = request('/auth/oidc/userinfo',extra=bearer(tokens['access_token'])) |
| 215 | assert identity == {'sub':guest,'preferred_username':'guest-github-123'} |
| 216 | provision('shale',[callback],{'oidc-test':'clover'},username=True) |
| 217 | request('/auth/oidc/userinfo',extra=bearer(tokens['access_token']),status=401) |
| 218 | request('/auth/oidc/token','POST',{'grant_type':'refresh_token','refresh_token':tokens['refresh_token']},extra=basic,form=True,status=400) |
| 219 | db.close() |
| 220 | print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed', |
| 221 | 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed', |
| 222 | 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed', |
| 223 | 'shale_openid_username_mapping': 'passed', 'guest_flow_cookie_and_provider_binding': 'passed', 'guest_scope_and_service_boundaries': 'passed'})) |
| 224 | finally: |
| 225 | if server and server.poll() is None: stop() |
| 226 | log.close() |
| 227 | |
| 228 | |
| 229 | if __name__ == '__main__': |
| 230 | main() |