1#!/usr/bin/env python3
2"""Exercise native OIDC over HTTP with independently verified RSA signatures."""
3import argparse
4import base64
5import hashlib
6import http.client
7import json
8import os
9from pathlib import Path
10import socket
11import sqlite3
12import subprocess
13import tempfile
14import time
15import urllib.parse
16import uuid
17from cryptography.hazmat.primitives import hashes
18from cryptography.hazmat.primitives.asymmetric import rsa, padding
19from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
20
21
22def b64(value):
23 return base64.urlsafe_b64encode(value).decode().rstrip('=')
24
25
26def 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
229if __name__ == '__main__':
230 main()