SHA256
test: exercise native Android flows against pinned upstream HTTPS and OIDC
Add an ephemeral HTTPS proxy and standards-shaped RS256/S256-PKCE test IdP around the unmodified upstream checkout. Require unlocked devices and nonempty, failure-free JUnit evidence. The extended API 35 suite passes all seven cases, including native Blob/file transfer, session recreation, outage recovery, isolated OIDC and back navigation; eight evidence-verifier regressions also pass. API 37 and real-provider/device validation remain release gates.
This commit is contained in:
1 parent
f7b9dcd91e
commit
9ca11e4fb5
7 files changed
+691
No files matched your search
@@ -0,0 +1,86 @@
|
||||
// Standards-shaped loopback test IdP, never shipped in the mobile apps.
|
||||
// It validates single-use codes, exact redirect URI, Basic client auth and S256
|
||||
// PKCE, and signs real RS256 ID tokens containing the original nonce.
|
||||
import https from 'node:https';
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
|
||||
export async function startIdentityProvider(tls) {
|
||||
const clientId = 'mobile-test-client';
|
||||
const secret = randomBytes(32).toString('hex');
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const key = { ...publicKey.export({ format: 'jwk' }), kid: 'fixture-key', alg: 'RS256', use: 'sig' };
|
||||
const codes = new Map();
|
||||
const access = new Set();
|
||||
const authenticatesClient = header => {
|
||||
if (!/^Basic /i.test(header || '')) return false;
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||
const separator = decoded.indexOf(':');
|
||||
const formDecode = value => decodeURIComponent(value.replace(/\+/g, ' '));
|
||||
try {
|
||||
return separator >= 0 && formDecode(decoded.slice(0, separator)) === clientId &&
|
||||
formDecode(decoded.slice(separator + 1)) === secret;
|
||||
} catch { return false; }
|
||||
};
|
||||
let issuer;
|
||||
let redirect;
|
||||
const profile = { sub: 'mobile-oidc-subject', preferred_username: 'mobile-oidc', name: 'Mobile OIDC Test',
|
||||
email: 'mobile-test@example.invalid', email_verified: true };
|
||||
const json = (response, status, data) => {
|
||||
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
response.end(JSON.stringify(data));
|
||||
};
|
||||
const idp = https.createServer(tls, async (request, response) => {
|
||||
const url = new URL(request.url, issuer);
|
||||
if (url.pathname === '/.well-known/openid-configuration') {
|
||||
return json(response, 200, { issuer, authorization_endpoint: issuer + '/authorize',
|
||||
token_endpoint: issuer + '/token', userinfo_endpoint: issuer + '/userinfo', jwks_uri: issuer + '/jwks',
|
||||
response_types_supported: ['code'], subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||
code_challenge_methods_supported: ['S256'] });
|
||||
}
|
||||
if (url.pathname === '/jwks') return json(response, 200, { keys: [key] });
|
||||
if (url.pathname === '/authorize') {
|
||||
const parameters = url.searchParams;
|
||||
if (parameters.get('client_id') !== clientId || parameters.get('redirect_uri') !== redirect ||
|
||||
parameters.get('code_challenge_method') !== 'S256' || !parameters.get('state') || !parameters.get('nonce')) {
|
||||
return json(response, 400, { error: 'invalid_request' });
|
||||
}
|
||||
const code = randomBytes(24).toString('hex');
|
||||
codes.set(code, { challenge: parameters.get('code_challenge'), nonce: parameters.get('nonce'), expires: Date.now() + 60_000 });
|
||||
const callback = new URL(redirect);
|
||||
callback.searchParams.set('code', code); callback.searchParams.set('state', parameters.get('state'));
|
||||
response.writeHead(302, { Location: callback.href, 'Cache-Control': 'no-store' }); response.end(); return;
|
||||
}
|
||||
if (url.pathname === '/token' && request.method === 'POST') {
|
||||
let body = '';
|
||||
for await (const chunk of request) { body += chunk; if (body.length > 8192) return json(response, 413, {}); }
|
||||
const parameters = new URLSearchParams(body);
|
||||
const code = parameters.get('code'); const grant = codes.get(code); codes.delete(code);
|
||||
if (!grant || grant.expires < Date.now() || parameters.get('grant_type') !== 'authorization_code' ||
|
||||
parameters.get('redirect_uri') !== redirect || !authenticatesClient(request.headers.authorization) ||
|
||||
createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') !== grant.challenge) {
|
||||
console.error('Test IdP grant rejected:', JSON.stringify({
|
||||
knownCode: !!grant, expired: !!grant && grant.expires < Date.now(),
|
||||
redirectMatches: parameters.get('redirect_uri') === redirect,
|
||||
clientMatches: authenticatesClient(request.headers.authorization),
|
||||
pkceMatches: !!grant && createHash('sha256').update(parameters.get('code_verifier') || '').digest('base64url') === grant.challenge,
|
||||
}));
|
||||
return json(response, 400, { error: 'invalid_grant' });
|
||||
}
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'RS256', kid: key.kid, typ: 'JWT' })).toString('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ iss: issuer, aud: clientId, sub: profile.sub, nonce: grant.nonce,
|
||||
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 })).toString('base64url');
|
||||
const token = randomBytes(24).toString('hex'); access.add(token);
|
||||
const signature = sign('RSA-SHA256', Buffer.from(header + '.' + payload), privateKey).toString('base64url');
|
||||
return json(response, 200, { access_token: token, token_type: 'Bearer', expires_in: 60, id_token: header + '.' + payload + '.' + signature });
|
||||
}
|
||||
if (url.pathname === '/userinfo' && access.has(request.headers.authorization?.replace(/^Bearer /, ''))) {
|
||||
return json(response, 200, profile);
|
||||
}
|
||||
json(response, 404, { error: 'not_found' });
|
||||
});
|
||||
idp.listen(0, '127.0.0.1'); await once(idp, 'listening');
|
||||
issuer = 'https://127.0.0.1:' + idp.address().port;
|
||||
return { issuer, clientId, secret, port: idp.address().port, setRedirect(value) { redirect = value; }, close() { idp.close(); } };
|
||||
}
|
||||
Reference in new issue
Block a user