diff --git a/development.env.template b/development.env.template index f7c979f..a7790d9 100644 --- a/development.env.template +++ b/development.env.template @@ -21,7 +21,8 @@ S3_REGION=replace.me APPLE_CLIENT_ID=com.replace.me APP_SECRET=replace.me GOOGLE_CLIENT_ID=replace.me.apps.googleusercontent.com -ANDROID_RELEASE_HASH=android:apk-key-hash:replace.me +# Comma-separated list: Play app-signing cert hash + upload-key cert hash (for locally-signed release builds) +ANDROID_RELEASE_HASH=android:apk-key-hash:replace.me,android:apk-key-hash:replace.me.too # Only required to process webhook events from RevenueCat REVENUECAT_HEADER= diff --git a/src/__tests__/services/PasskeyService.test.ts b/src/__tests__/services/PasskeyService.test.ts index d82b6ee..5686b05 100644 --- a/src/__tests__/services/PasskeyService.test.ts +++ b/src/__tests__/services/PasskeyService.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import moment from 'moment'; import { randomUUID, randomBytes } from 'crypto'; import { PasskeyService } from '../../services/PasskeyService'; @@ -585,4 +585,43 @@ describe('PasskeyService', () => { }); }); }); + + describe('expectedOrigins', () => { + const originalHash = process.env.ANDROID_RELEASE_HASH; + + afterEach(() => { + process.env.ANDROID_RELEASE_HASH = originalHash; + }); + + it('should allow the web origin plus a single android hash', () => { + process.env.ANDROID_RELEASE_HASH = 'android:apk-key-hash:playSigningHash'; + const svc = new PasskeyService(); + expect((svc as any).expectedOrigins).toEqual([ + `https://${process.env.WEBAUTHN_RP_ID}`, + 'android:apk-key-hash:playSigningHash', + ]); + }); + + it('should split a comma-separated list into multiple android origins', () => { + // Play App Signing: store builds assert with Google's app-signing cert, locally-signed + // release builds assert with the upload cert — both hashes must be accepted. + process.env.ANDROID_RELEASE_HASH = + 'android:apk-key-hash:playSigningHash, android:apk-key-hash:uploadKeyHash'; + const svc = new PasskeyService(); + expect((svc as any).expectedOrigins).toEqual([ + `https://${process.env.WEBAUTHN_RP_ID}`, + 'android:apk-key-hash:playSigningHash', + 'android:apk-key-hash:uploadKeyHash', + ]); + }); + + it('should ignore empty segments from trailing commas', () => { + process.env.ANDROID_RELEASE_HASH = 'android:apk-key-hash:playSigningHash,'; + const svc = new PasskeyService(); + expect((svc as any).expectedOrigins).toEqual([ + `https://${process.env.WEBAUTHN_RP_ID}`, + 'android:apk-key-hash:playSigningHash', + ]); + }); + }); }); diff --git a/src/services/PasskeyService.ts b/src/services/PasskeyService.ts index c37d285..65e69d2 100644 --- a/src/services/PasskeyService.ts +++ b/src/services/PasskeyService.ts @@ -36,10 +36,15 @@ export class PasskeyService { private readonly rpName = process.env.WEBAUTHN_RP_NAME; private readonly androidReleaseHash = process.env.ANDROID_RELEASE_HASH; private readonly origin = `https://${this.rpID}`; - // Origins allowed to complete WebAuthn ceremonies: the web origin plus the - // Android app's signing-key origin (android:apk-key-hash:...). ANDROID_RELEASE_HASH - // is validated as required at boot (config/envs.ts), so it's always present. - private readonly expectedOrigins = [this.origin, this.androidReleaseHash!]; + // Origins allowed to complete WebAuthn ceremonies: the web origin plus the Android app's + // signing-key origins (android:apk-key-hash:...). ANDROID_RELEASE_HASH is validated as + // required at boot (config/envs.ts) and accepts a comma-separated list: with Play App + // Signing the store build asserts with Google's app-signing cert while locally-signed + // release builds assert with the upload cert — both need to be allowed. + private readonly expectedOrigins = [ + this.origin, + ...this.androidReleaseHash!.split(',').map((hash) => hash.trim()).filter(Boolean), + ]; private readonly challengeTTL = 300; // Registration