# Auth: Flutter guide

This guide covers guest mode, Sign in with Apple, Sign in with Google, keeping the user logged in, and account deletion.

## Basics for every endpoint

- **Base URL (local dev):** `http://localhost:3100` from the iOS Simulator. On a real iPhone, use your Mac's local IP address, for example `http://192.168.1.20:3100`.
- **Plain `http` in dev:** iOS blocks plain `http` by default. Add `NSAppTransportSecurity` → `NSAllowsLocalNetworking = YES` to `Info.plist`, in debug builds only.
- **API docs page:** `http://localhost:3100/api/docs` (dev only). You can try every endpoint there.
- **Auth header:** after sign-in, send `Authorization: Bearer <token>` on every request.
- **Errors:** every error has the same shape:

```json
{ "error": { "code": "REAUTH_REQUIRED", "message": "Confirm it is you before deleting the account." } }
```

Use `code` in your logic. `message` is for logs. Write your own text for the user.

| Code | Status | Meaning |
|---|---|---|
| `VALIDATION_ERROR` | 400 | The request body is wrong. A bug in the app. |
| `UNAUTHORIZED` | 401 | Token missing, expired, or no longer valid. See "When you get 401". |
| `RATE_LIMITED` | 429 | Too many requests. Wait and retry. |
| `INTERNAL_ERROR` | 500 | Server bug. Show "Something went wrong". |

## Packages

| Package | Use |
|---|---|
| `sign_in_with_apple` | Apple sign-in sheet and the credential state check |
| `flutter_secure_storage` | iOS Keychain storage |
| `dio` | HTTP client with interceptors |
| `crypto` | SHA-256 of the nonce |
| `google_sign_in` (7.x) | Google sign-in screen |

In Xcode, add the **Sign in with Apple** capability to the Runner target.

For Google, in `ios/Runner/Info.plist`:

1. Add `GIDClientID` = the iOS client ID (ask the backend team; it is the server's `GOOGLE_CLIENT_ID`).
2. Add a URL type (`CFBundleURLTypes`) whose URL scheme is the **reversed** client ID, for example `com.googleusercontent.apps.1234-abcd`.

Apple requires Sign in with Apple whenever Google sign-in is offered. Show both buttons.

## What to store in the Keychain

| Key | Value | When to delete it |
|---|---|---|
| `guest_secret` | The guest secret | After the guest signs in with Apple, and after account deletion |
| `token` | Our login token | On logout, deletion, or 401 |
| `apple_user_id` | Apple's `userIdentifier` | On logout or deletion |
| `google_user_id` | Google's `account.id` | On logout or deletion |

Do **not** clear the Keychain on first launch. Many apps do this. It would break the "reinstall gives back the same guest" feature.

## 1. App launch

Follow these steps in order:

1. If `token` exists and `apple_user_id` exists, call `SignInWithApple.getCredentialState(appleUserId)`.
   - `revoked` or `notFound` → sign out (delete `token` and `apple_user_id`), then go to step 3.
   - `authorized` → go on to step 2.
2. If `token` exists, call `GET /me`.
   - `200` → the user is logged in. Done.
   - `401` → delete `token`, then go to step 3.
3. No token: if `apple_user_id` or `google_user_id` was there before, show the sign-in screen. Otherwise, log in as a guest (section 2).

Google has no credential state check. A Google user stays signed in until our token stops working.

## 2. Guest login

Make the secret once and keep it forever:

```dart
import 'dart:convert';
import 'dart:math';

String newGuestSecret() {
  final rand = Random.secure();
  final bytes = List<int>.generate(32, (_) => rand.nextInt(256));
  return base64Url.encode(bytes).replaceAll('=', ''); // 43 characters
}

Future<String> guestSecret() async {
  var secret = await storage.read(key: 'guest_secret');
  if (secret == null) {
    secret = newGuestSecret();
    await storage.write(key: 'guest_secret', value: secret);
  }
  return secret;
}
```

Call:

```
POST /auth/guest
{ "secret": "<guest secret>" }
```

Response `200`:

```json
{
  "token": "eyJhbGciOi...",
  "user": {
    "id": "4ee690f1-853c-4ec0-bbc0-6371bd8f1da0",
    "isGuest": true,
    "email": null,
    "name": null,
    "plan": "FREE",
    "planExpiresAt": null,
    "unitSystem": "US"
  }
}
```

Save `token`. Calling this again with the same secret returns the same user. Use this whenever a guest's token stops working.

## 3. Sign in with Apple

Use this for a new user, a returning user, and a guest who wants to keep their data. The call is the same for all three.

First get a nonce: a random value that ties this sign-in to our server. It is valid for 5 minutes, so get a fresh one every time you show the Apple sheet.

```
GET /auth/nonce
```

Response `200`: `{ "nonce": "1793791048623.uvlTjNnv...e1bJa3Hl..." }`

Give Apple the **SHA-256 hash** of the nonce. Send the server the **raw** nonce.

```dart
import 'dart:convert';
import 'package:crypto/crypto.dart';

Future<(AuthorizationCredentialAppleID, String)> appleSheet(List<AppleIDAuthorizationScopes> scopes) async {
  final res = await dio.get('/auth/nonce');
  final rawNonce = res.data['nonce'] as String;
  final credential = await SignInWithApple.getAppleIDCredential(
    scopes: scopes,
    nonce: sha256.convert(utf8.encode(rawNonce)).toString(),
  );
  return (credential, rawNonce);
}

final (credential, rawNonce) = await appleSheet(
  [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
);

final fullName = [credential.givenName, credential.familyName]
    .whereType<String>()
    .join(' ');
```

Call (if the user is a guest now, send their guest token in the `Authorization` header):

```
POST /auth/apple
Authorization: Bearer <guest token>     (only if the user is a guest)
{
  "identityToken": "<credential.identityToken>",
  "authorizationCode": "<credential.authorizationCode>",
  "nonce": "<rawNonce>",
  "fullName": "Sam Lee"                 (send null if empty)
}
```

Response `200`: same shape as guest login. `user.isGuest` is `false`.

Then:

1. Save `token`.
2. Save `credential.userIdentifier` as `apple_user_id`.
3. Delete `guest_secret`. The server has already turned the guest into an Apple account.

**The name comes only once.** Apple gives `givenName` and `familyName` only on the very first sign-in. Always send them when present. If the request fails, retry with the same values. Do not show the sheet again, or the name is lost.

Errors:

| Code | Status | What to do |
|---|---|---|
| `INVALID_APPLE_TOKEN` | 401 | Show "Apple sign-in failed. Try again." Also returned when the nonce is wrong or older than 5 minutes. |
| `ACCOUNT_EXISTS` | 409 | Only for guests. This Apple ID already has an account. Ask: "You already have an account. Switch to it? Recipes saved as a guest stay on this phone's guest account." If yes: call `POST /auth/apple` again with the same body, **without** the guest token. This works within 5 minutes. Later, show the Apple sheet again with a new nonce. Save the new token. Keep `guest_secret`. |

## 4. Sign in with Google

Use this for a new user, a returning user, and a guest who wants to keep their data. Same rules as Apple. No nonce.

Call `initialize` once, at app start:

```dart
import 'package:google_sign_in/google_sign_in.dart';

await GoogleSignIn.instance.initialize();
```

Then, when the user taps the Google button:

```dart
Future<GoogleSignInAccount> googleSheet() async {
  // Always the full sign-in screen. Do not use attemptLightweightAuthentication():
  // it can return an old ID token, and the server only takes tokens less than 5 minutes old.
  return GoogleSignIn.instance.authenticate();
}

final account = await googleSheet();
final idToken = account.authentication.idToken!;
```

Call right away (if the user is a guest now, send their guest token in the `Authorization` header):

```
POST /auth/google
Authorization: Bearer <guest token>     (only if the user is a guest)
{ "idToken": "<idToken>" }
```

Response `200`: same shape as guest login. `user.isGuest` is `false`. The server takes the name and email from the token.

Then:

1. Save `token`.
2. Save `account.id` as `google_user_id`.
3. Delete `guest_secret`.

Errors:

| Code | Status | What to do |
|---|---|---|
| `INVALID_GOOGLE_TOKEN` | 401 | Show "Google sign-in failed. Try again." Also returned when the ID token is more than 5 minutes old. Call `GoogleSignIn.instance.signOut()` and let the user try again. |
| `ACCOUNT_EXISTS` | 409 | Only for guests. This Google account already has an account. Ask: "You already have an account. Switch to it?" If yes: call `POST /auth/google` again with the same `idToken`, **without** the guest token. This works within 5 minutes of sign-in. Later, show Google sign-in again. Save the new token. Keep `guest_secret`. |

**Apple and Google are separate accounts.** If a user signs in with Apple once and with Google later, they get two accounts with different data. Show which one they used on the profile screen.

## 5. Get the current user

```
GET /me
Authorization: Bearer <token>
```

Response `200`: the `user` object from above. Use `isGuest` to decide whether to show "Sign in with Apple to keep your recipes safe".

## 6. When you get 401

Add a `dio` interceptor for any `401`:

- **Guest** (neither `apple_user_id` nor `google_user_id` stored): call `POST /auth/guest` with the stored secret, save the new token, and retry the request once.
- **Apple or Google user:** delete `token` and show the sign-in screen.

The token lasts 1 year. A `401` usually means the account was deleted, the user turned off Sign in with Apple in iOS Settings, or the token expired.

## 7. Delete account

Apple requires a "Delete account" button in the app. The server asks for fresh proof first.

**Apple user:**

1. Show the Apple sheet again with a new nonce: `appleSheet([])`.
2. Call:

```
DELETE /me
Authorization: Bearer <token>
{
  "identityToken": "<new identityToken>",
  "authorizationCode": "<new authorizationCode>",
  "nonce": "<new rawNonce>"
}
```

**Google user:**

1. Show Google sign-in again: `googleSheet()`.
2. Call right away:

```
DELETE /me
Authorization: Bearer <token>
{ "identityToken": "<new Google idToken>" }
```

**Guest:**

```
DELETE /me
Authorization: Bearer <token>
{ "secret": "<guest secret>" }
```

With `dio`: `dio.delete('/me', data: {...})`.

Response `204` (no body). Then delete `token`, `apple_user_id`, `google_user_id`, and `guest_secret` from the Keychain. For Google users, also call `GoogleSignIn.instance.signOut()`. Then go to the first screen.

Errors:

| Code | Status | What to do |
|---|---|---|
| `REAUTH_REQUIRED` | 403 | The proof was wrong: a different Apple ID or Google account, a Google token more than 5 minutes old, or a wrong secret. Nothing was deleted. Show "Could not confirm your account." |

## Checklist

- [ ] Sign in with Apple capability in Xcode
- [ ] Guest secret made once, kept in the Keychain, never cleared on first launch
- [ ] Credential state check on launch for Apple users
- [ ] `401` interceptor: guests log in again silently, Apple and Google users see the sign-in screen
- [ ] Fresh nonce from `GET /auth/nonce` before every Apple sheet. Hash to Apple, raw to the server.
- [ ] Name sent on the first Apple sign-in
- [ ] `GIDClientID` and the reversed client ID URL scheme in `Info.plist`
- [ ] `GoogleSignIn.instance.initialize()` once at start. `authenticate()` for every sign-in, then call the server right away.
- [ ] `guest_secret` deleted after the guest signs in with Apple or Google
- [ ] `409 ACCOUNT_EXISTS` handled
- [ ] Delete account with fresh proof, then clear the Keychain
