Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
*/
public const string SignInPath = 'devices';

/**
* The version of the terms and privacy policy the sign-in screen asks people to accept, which devnull records when
* the phone signs in. It's the date they took effect, and has to be the one devnull has in force.
*/
public const string TermsVersion = '2026-10-23';

/**
* Where the signed-in account's Apple ID is linked and unlinked.
*/
Expand Down Expand Up @@ -131,22 +137,27 @@ public static function pastelPath(string $id): string
* phone knows, for the user it approved this phone for. It's signed like
* every request, so the code only works for the key it was made for.
*/
public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User
public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier, ?string $termsVersion = null): User
{
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: ['code' => $code, 'code_verifier' => $verifier])));
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [
'code' => $code,
'code_verifier' => $verifier,
...($termsVersion === null ? [] : ['terms_version' => $termsVersion]),
])));
}

/**
* Signs in on a phone devnull doesn't know yet with an Apple ID linked to
* an account, which approves this phone's key. Apple's identity token
* carries sha256 of the nonce, and devnull checks it against the raw one.
*/
public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User
public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName, ?string $termsVersion = null): User
{
return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [
'apple_identity_token' => $identityToken,
'nonce' => $nonce,
'device_name' => $deviceName,
...($termsVersion === null ? [] : ['terms_version' => $termsVersion]),
])));
}

Expand Down
29 changes: 25 additions & 4 deletions app/NativeComponents/Onboarding.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ class Onboarding extends NativeComponent
*/
public ?string $appleError = null;

/**
* Whether the box for the terms and privacy policy is ticked. Neither way in works until it is, and devnull is told
* which version was accepted as the phone signs in.
*/
public bool $acceptedTerms = false;

private string $deviceName = '';

/**
Expand Down Expand Up @@ -93,13 +99,28 @@ public function onResume(): void
$this->finishSignIn();
}

public function toggleTerms(): void
{
$this->acceptedTerms = ! $this->acceptedTerms;
}

public function openTerms(): void
{
Browser::inApp(rtrim((string) config('devnull.url'), '/').'/terms');
}

public function openPrivacy(): void
{
Browser::inApp(rtrim((string) config('devnull.url'), '/').'/privacy');
}

/**
* Opens dev-null.sh with a fresh verifier and state. Dismissing the sheet
* sends nothing back, so the screen simply stays as it is.
*/
public function signIn(): void
{
if ($this->fingerprint === '') {
if ($this->fingerprint === '' || ! $this->acceptedTerms) {
return;
}

Expand All @@ -117,7 +138,7 @@ public function signIn(): void
*/
public function signInWithApple(): void
{
if ($this->fingerprint === '' || ! AppleSignIn::isAvailable()) {
if ($this->fingerprint === '' || ! $this->acceptedTerms || ! AppleSignIn::isAvailable()) {
return;
}

Expand Down Expand Up @@ -149,7 +170,7 @@ public function render(): View
protected function useAppleToken(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce): void
{
try {
$user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName());
$user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName(), $this->acceptedTerms ? Client::TermsVersion : null);
} catch (ApiException $exception) {
$this->appleError = $exception->firstError();

Expand Down Expand Up @@ -220,7 +241,7 @@ private function finishSignIn(): void
}

try {
$user = app(Client::class)->redeemSignIn($code, $attempt->verifier());
$user = app(Client::class)->redeemSignIn($code, $attempt->verifier(), $this->acceptedTerms ? Client::TermsVersion : null);
} catch (ApiException $exception) {
$this->error = $exception->firstError();

Expand Down
26 changes: 24 additions & 2 deletions resources/views/native/onboarding.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,34 @@
<native:text ref="apple-error" font="mono" class="w-full text-center text-[13px] leading-snug text-theme-destructive">{{ $appleError }}</native:text>
@endif

{{-- What Apple asks of an app with user-generated content: agree to the terms before you're in. Neither button works until it's ticked. --}}
<native:row ref="terms" class="w-full gap-3 items-start">
<native:pressable ref="accept-terms" @press="toggleTerms" class="w-[44] h-[44] -ml-3 items-center justify-center" a11y-label="Agree to the terms of service and privacy policy" :a11y-value="$acceptedTerms ? 'ticked' : 'not ticked'">
<native:column class="w-[22] h-[22] rounded-md border-2 items-center justify-center {{ $acceptedTerms ? 'border-theme-primary bg-theme-primary' : 'border-theme-ink-muted' }}">
@if ($acceptedTerms)
<native:icon :ios="\App\Icons\Lucide::Check->ios()" :android="\App\Icons\Lucide::Check->android()" :size="14" class="text-white" />
@endif
</native:column>
</native:pressable>
<native:column class="flex-1 gap-1 pt-[10]">
<native:text ref="terms-copy" class="text-[13px] leading-snug text-theme-ink">I agree to the terms of service and privacy policy. There's no tolerance for objectionable content or abusive users.</native:text>
<native:row class="gap-4">
<native:pressable ref="terms-link" @press="openTerms" class="h-[32] justify-center" a11y-label="Read the terms of service">
<native:text class="text-[13px] text-theme-primary">Terms of service</native:text>
</native:pressable>
<native:pressable ref="privacy-link" @press="openPrivacy" class="h-[32] justify-center" a11y-label="Read the privacy policy">
<native:text class="text-[13px] text-theme-primary">Privacy policy</native:text>
</native:pressable>
</native:row>
</native:column>
</native:row>

{{-- Black with white text (white with black in dark mode, the theme's accent), large, with GitHub's mark. --}}
<native:button ref="sign-in" variant="accent" size="lg" :ios-icon="Icon::custom('github')" :android-icon="Icon::custom('github')" :disabled="$fingerprint === ''" @press="signIn" class="w-full">{{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in with GitHub' }}</native:button>
<native:button ref="sign-in" variant="accent" size="lg" :ios-icon="Icon::custom('github')" :android-icon="Icon::custom('github')" :disabled="$fingerprint === '' || ! $acceptedTerms" @press="signIn" class="w-full">{{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in with GitHub' }}</native:button>

{{-- Apple's button the same: the accent with its logo, as big as GitHub's, as Apple's guidelines ask. --}}
@if ($offersApple)
<native:button ref="sign-in-apple" variant="accent" size="lg" :ios-icon="Ios::AppleLogo" :disabled="$fingerprint === ''" @press="signInWithApple" class="w-full">Sign in with Apple</native:button>
<native:button ref="sign-in-apple" variant="accent" size="lg" :ios-icon="Ios::AppleLogo" :disabled="$fingerprint === '' || ! $acceptedTerms" @press="signInWithApple" class="w-full">Sign in with Apple</native:button>
@endif
</native:column>
</native:column>
44 changes: 22 additions & 22 deletions tests/Feature/Screens/OnboardingAppleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
$buttons = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button');

expect(array_column($buttons, 'ref'))->toBe(['sign-in', 'sign-in-apple'])
->and(childRefs($screen, 'actions'))->toBe(['sign-in', 'sign-in-apple'])
->and(shownText($screen))->toBe([])
->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in', 'sign-in-apple'])
->and(shownText($screen))->toBe(termsText())
->and($buttons[1]['props'])->toMatchArray(['label' => 'Sign in with Apple', 'variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'apple.logo'])
->and($buttons[0]['props'])->toMatchArray(['variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'custom:github'])
->and($buttons[1]['layout'])->toEqual($buttons[0]['layout'])->toEqual(['width' => 'fill'])
Expand All @@ -49,7 +49,7 @@

expect(AppleSignIn::isAvailable())->toBeFalse()
->and((new NativeServiceProvider(app()))->plugins())->not->toContain(NativephpSocialAuthServiceProvider::class)
->and(childRefs($screen, 'actions'))->toBe(['sign-in']);
->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']);

fakeApi(['GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0, 'apple_linked' => false]]]);
signIn();
Expand All @@ -64,14 +64,14 @@
$bridge = appleBridge();
$screen = Native::visit('/onboarding', platform: 'ios');

$screen->tap('sign-in-apple')->assertNoNavigation();
$screen->set('acceptedTerms', true)->tap('sign-in-apple')->assertNoNavigation();
$first = appleSheetParams($bridge);

expect($first['nonce'])->toBe(heldAppleNonce())->toMatch('/^[0-9a-f]{64}$/')
->and($first['scopes'])->toBe([])
->and($first['state'])->toBeNull();

$screen->tap('sign-in-apple');
$screen->set('acceptedTerms', true)->tap('sign-in-apple');
$second = appleSheetParams($bridge);

expect($second['nonce'])->toBe(heldAppleNonce())->not->toBe($first['nonce']);
Expand All @@ -86,7 +86,7 @@
'POST /api/v1/devices' => [['data' => userWithCountsJson()]],
'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson()]],
]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();

appleAnswers($screen, $nonce)->assertReplacedWith('/log');
Expand All @@ -99,7 +99,7 @@
Http::assertSentCount(2);
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath
&& $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone'])
&& $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone', 'terms_version' => Client::TermsVersion])
&& $request->hasHeader('X-SSH-Signature'));

// Apple's token carries sha256(nonce) as hex, which is what devnull checks against the raw nonce.
Expand All @@ -110,7 +110,7 @@
appleBridge();
$message = ErrorCode::AppleNotLinked->message();
fakeApi(['POST /api/v1/devices' => [['message' => 'No /dev/null account is linked to this Apple ID.', 'code' => 'apple_not_linked'], 404]]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

$screen = appleAnswers($screen, heldAppleNonce())
->assertNoNavigation()
Expand All @@ -119,16 +119,16 @@
->assertSet('error', null)
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in with GitHub');

expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'sign-in', 'sign-in-apple'])
->and(shownText($screen))->toBe([$message]);
expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'terms', 'sign-in', 'sign-in-apple'])
->and(shownText($screen))->toBe([$message, ...termsText()]);

expect(app(Account::class)->user())->toBeNull();
});

it("says when devnull can't verify Apple's token, without turning GitHub into Try again", function () {
appleBridge();
fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

appleAnswers($screen, heldAppleNonce())
->assertNoNavigation()
Expand All @@ -142,15 +142,15 @@
it("returns quietly when Apple's sheet is closed", function () {
appleBridge();
fakeApi([]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

$screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'Sign in with Apple was cancelled.', 'errorCode' => AppleSignIn::Cancelled])
->assertNoNavigation()
->assertSet('appleError', null)
->assertSet('error', null)
->assertDontSee('cancelled');

expect(shownText($screen))->toBe([]);
expect(shownText($screen))->toBe(termsText());

expect(heldAppleNonce())->toBeNull();
Http::assertNothingSent();
Expand All @@ -159,7 +159,7 @@
it("says so when Apple's sign-in fails another way, and ignores Google's failures", function () {
appleBridge();
fakeApi([]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

$screen->emitNative(SignInFailed::class, ['provider' => 'google', 'error' => 'Nope.', 'errorCode' => 'SIGN_IN_FAILED'])
->assertSet('appleError', null);
Expand All @@ -176,7 +176,7 @@
it('rejects an answer whose nonce is not the one it sent, and sends nothing', function (?string $nonce) {
appleBridge();
fakeApi([]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

appleAnswers($screen, '', ['nonce' => $nonce])
->assertNoNavigation()
Expand All @@ -201,7 +201,7 @@
it('spends an attempt on its first answer, so a replayed one is rejected', function () {
appleBridge();
fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();

appleAnswers($screen, $nonce);
Expand All @@ -213,7 +213,7 @@
it('says so when Apple sends no identity token', function () {
appleBridge();
fakeApi([]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

appleAnswers($screen, heldAppleNonce(), ['identityToken' => null])->assertSet('appleError', AppleSignIn::NoToken);

Expand All @@ -224,7 +224,7 @@
fakeKeychain(testSeed())->respondTo('NativephpSocialAuth.AppleSignIn', null);

Native::visit('/onboarding', platform: 'ios')
->tap('sign-in-apple')
->set('acceptedTerms', true)->tap('sign-in-apple')
->assertSee(AppleSignIn::CouldNotOpen);

expect(heldAppleNonce())->toBeNull();
Expand All @@ -238,7 +238,7 @@
});
$bridge = appleBridge();
fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');
$nonce = heldAppleNonce();

expect(print_r(app(AppleSignIn::class), true))->not->toContain($nonce)
Expand Down Expand Up @@ -267,7 +267,7 @@

Native::visit('/onboarding', platform: 'ios')
->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in-apple' && ($node['props']['disabled'] ?? false) === true)
->call('signInWithApple');
->set('acceptedTerms', true)->call('signInWithApple');

$bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn');
});
Expand All @@ -277,7 +277,7 @@

$screen = Native::visit('/onboarding', platform: 'android')
->assertDontSee('Sign in with Apple')
->call('signInWithApple');
->set('acceptedTerms', true)->call('signInWithApple');

expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'))->toHaveCount(1);
$bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn');
Expand All @@ -286,7 +286,7 @@
it("asks whether the phone has an Apple Account when Apple's error says nothing more", function () {
appleBridge();
fakeApi([]);
$screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple');
$screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple');

// What closing iOS's "Sign in to your Apple Account" alert sends back.
$screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1000.) (ASAuthorizationError.unknown)', 'errorCode' => 'SIGN_IN_FAILED'])
Expand Down
Loading
Loading