Autentisering og abonnement
Impulse AI bruker native-only autentisering — Sign in with Apple (iOS) og Sign in with Google (Android). Ingen email/passord, ingen tredjepartslogins. Abonnement håndteres via App Store / Google Play med en binær tilgangsmodell.
Arkitekturoversikt
heading.anchorLabel┌──────────────────────────────────────────────────┐│ iOS / Android App ││ ││ Native Sign-In ──▶ KMP AuthManager ──▶ StoreKit ││ (ASAuth/Google) (token lifecycle) (IAP) │└──────────────────────┬────────────────────────────┘ │ Bearer JWT ▼┌──────────────────────────────────────────────────┐│ API (Railway) ││ ││ POST /api/v1/auth/native ◀── sign-in ││ POST /api/v1/auth/refresh ◀── token refresh ││ authenticateJWT(req) ◀── alle andre kall ││ isSubscriptionActive() ◀── tilgangskontroll │└──────────────────────┬────────────────────────────┘ │ ▼┌──────────────────────────────────────────────────┐│ Supabase (PostgreSQL) ││ ││ auth.users ──trigger──▶ profiles ││ (Apple/Google ID) (tier, status, trial) ││ ││ iap_subscriptions (Apple/Google tilstand) │└──────────────────────────────────────────────────┘Designbeslutninger
heading.anchorLabel| Beslutning | Begrunnelse |
|---|---|
| Kun native sign-in | Auth-identitet = Store-identitet = Subscription-identitet. Én rett linje. |
| Ingen email/passord | Eliminerer forgot-password, brute-force, credential stuffing. Apple/Google har allerede verifisert brukeren. |
| Binær tilgangsmodell | Aktiv = tilgang. Ikke aktiv = paywall. Ingen «graceful expiry». |
isSubscriptionActive() som single source of truth | Én funksjon, brukt av både middleware og quota-service. |
| Separate auth- og notification-email | Apple relay-email kan ikke motta kommunikasjon. Bruker setter notification_email selv. |
Autentiseringsflyt
heading.anchorLabelSign-in (ny bruker eller returning)
heading.anchorLabel1. Bruker trykker "Sign in with Apple"2. iOS viser native Apple Sign-In (Face ID / passord)3. Apple returnerer ID token + nonce4. App sender til API: POST /api/v1/auth/native5. API kaller supabase.auth.signInWithIdToken()6. Supabase verifiserer med Apple, oppretter auth.users om ny7. DB-trigger oppretter profiles-rad (7-dagers trial)8. API returnerer JWT-par (access + refresh token)9. KMP AuthManager lagrer tokens i Keychain10. AuthStateWrapper oppdateres → ImpulseApp viser ContentViewToken refresh
heading.anchorLabel1. API-kall returnerer 401 (token utløpt)2. ApiClient interceptor prøver POST /api/v1/auth/refresh3. Supabase roterer tokens4. Nytt JWT-par lagres i AuthManager5. Originalt API-kall prøves på nytt med nytt token6. Hvis refresh feiler → AuthManager.logout() → LoginViewLogout
heading.anchorLabel1. Bruker trykker "Sign out" i ProfileView2. KMP ProfileStore kaller authManager.logout()3. Tokens slettes fra Keychain4. AuthState → Unauthenticated5. AuthStateWrapper detekterer → KMPStoreProvider.reset() (sletter cached data)6. ImpulseApp observerer → viser LoginViewAPI-endepunkter
heading.anchorLabelPOST /api/v1/auth/native
heading.anchorLabelAutentisering med plattform-ID-token. Ingen auth middleware (dette ER auth-endepunktet).
// Request{ provider: "apple" | "google", id_token: string, // OIDC ID token fra Apple/Google nonce?: string // For Apple (nonce-verifisering)}
// Response{ success: true, data: { access_token: string, // Supabase JWT (1h TTL) refresh_token: string, // For token refresh expires_in: number, user: { id: string, // UUID email: string, is_new_user: boolean // true = første sign-in (trial startet) } }}POST /api/v1/auth/refresh
heading.anchorLabelFornyer utløpt access token.
// Request{ refresh_token: string }
// Response{ success: true, data: { access_token: string, refresh_token: string, // Rotert refresh token expires_in: number }}Subscription-modell
heading.anchorLabelTo kategorier tiers
heading.anchorLabelTiers er delt i to kategorier basert paa hvordan tilgang styres:
| Kategori | Tiers | Datakilde | Aktivering |
|---|---|---|---|
| Profiles-only | Trial, Partner | Kun profiles-tabellen | Trigger (trial) / Admin (partner) |
| IAP-backed | Foundation, Mastery | profiles + iap_subscriptions | App Store / Google Play kjoep |
Profiles-only tiers har ingen rad i iap_*-tabellene. All tilstandsinformasjon lever i profiles.
Tier-oversikt
heading.anchorLabel| Tier | Pris | Impulser | Sjekk-inn | Kilde |
|---|---|---|---|---|
| Trial | Gratis | 3 totalt | 7 totalt | DB-trigger ved sign-up (7 dager) |
| Partner | Gratis | 30/mnd | 90/mnd | Admin-aktivering (partner-service.ts) |
| Impulse (foundation) | $6/mnd | 30/mnd | 90/mnd | StoreKit / Google Play kjoep |
| Pro (mastery) | $12/mnd | 60/mnd | 180/mnd | StoreKit / Google Play kjoep |
Kvoter er konfigurerbare i subscription_config-tabellen uten redeploy.
Livssyklus per tier-kategori
heading.anchorLabelProfiles-only (trial/partner):
Ny bruker → trigger → profiles (trial/active, 7d)Trial utloeper → profiles (trial/expired) → paywallPartner-aktivering → profiles (partner/active) → ingen utloepPartner-deaktivering → profiles (trial/expired) → paywallIAP-backed (foundation/mastery):
Bruker kjoeper i StoreKit → POST /subscriptions/sync → iap_subscriptions opprettes (Apple-tilstand) → profiles oppdateres (tier + status)Fornyelse → App Store webhook → iap_subscriptions + profiles oppdateresKansellering → webhook → profiles (cancelled, aktiv til subscription_end_date)Utloep → webhook → profiles (expired) → paywallTilgangskontroll: isSubscriptionActive()
heading.anchorLabelEen funksjon som brukes av baade subscription-middleware og quota-service:
function isSubscriptionActive(tier, status, trialEndsAt, subscriptionEndDate): boolean| Tilstand | Kategori | Resultat |
|---|---|---|
Trial med gyldig trial_ends_at | Profiles-only | ✅ Aktiv |
| Trial utloept | Profiles-only | ❌ Paywall |
Partner med is_partner=true, status=active | Profiles-only | ✅ Aktiv |
Paid status='active' | IAP-backed | ✅ Aktiv |
Cancelled med gyldig subscription_end_date | IAP-backed | ✅ Aktiv |
Cancelled etter subscription_end_date | IAP-backed | ❌ Paywall |
| Expired / inactive | Begge | ❌ Paywall |
Ingen grace_period paa profile-nivaa. Apple haandterer billing retry og grace period. Naar Apple sender EXPIRED-webhook, settes profiles.subscription_status = 'expired'.
Kvote-gating
heading.anchorLabelNaar bruker proever aa opprette impuls eller sjekk-inn:
1. Klient: quotaStore.canCapture → false → vis paywall2. Server: canCaptureImpulse(userId) → false → 403 IMPULSE_QUOTA_EXCEEDEDDobbel sjekk: klient gates for UX, server enforcer uansett.
Databasetabeller
heading.anchorLabelprofiles (alle brukere — appens brukerrepresentasjon)
heading.anchorLabelAppens primaere bruker-entitet. Opprettes av DB-trigger naar auth.users-rad insertes. Alle tiers (trial, partner, foundation, mastery) har en rad her.
| Kolonne | Type | Beskrivelse |
|---|---|---|
id | UUID | FK → auth.users |
email | TEXT | Fra Apple/Google (kan vaere relay-adresse) |
notification_email | TEXT? | Brukervalgt adresse for kommunikasjon |
auth_provider | TEXT? | apple eller google |
subscription_tier | TEXT | trial, foundation, mastery, partner |
subscription_status | TEXT | active, expired, inactive, cancelled |
subscription_end_date | TIMESTAMPTZ? | Naar IAP-subscription utloeper/fornyes |
subscription_cancelled_at | TIMESTAMPTZ? | Naar bruker kansellerte |
trial_started_at | TIMESTAMPTZ? | Trial-start (satt av trigger) |
trial_ends_at | TIMESTAMPTZ? | Trial-slutt (konfigurerbar via subscription_config) |
trial_converted | BOOLEAN | Om trial konverterte til betalt |
is_partner | BOOLEAN | Partner-status |
partner_source | TEXT? | influencer, beta_tester, ambassador, marketing |
iap_subscriptions (kun betalte brukere)
heading.anchorLabelOpprettes foerst naar en bruker kjoeper via App Store / Google Play. Trial- og partner-brukere har ingen rad her.
| Kolonne | Beskrivelse |
|---|---|
platform | ios eller android |
platform_product_id | f.eks. im.foundation.monthly |
status | Apple/Google-tilstand (active, grace_period, billing_retry, expired) |
expires_date | Naar subscription fornyes eller utloeper |
auto_renew_enabled | Om auto-renewal er paa |
Merk: iap_subscriptions.status har Apples detaljerte tilstander. profiles.subscription_status har vaare forenklede tilstander. Mapping skjer i webhook-handlers.
iap_transactions / iap_webhook_events (kun IAP-backed)
heading.anchorLabelLogger for betalingstransaksjoner og App Store/Google Play webhooks. Brukes for feilsoeking og idempotent webhook-prosessering. Aldri involvert for trial/partner.
KMP Auth-lag
heading.anchorLabelshared/src/commonMain/kotlin/com/im/shared/ auth/ AuthManager.kt # Singleton. Token lifecycle, authState StateFlow. OAuthService.kt # Koordinerer native sign-in → API exchange. OAuthProvider.kt # enum APPLE/GOOGLE + NativeOAuthHandler interface TokenStorage.kt # expect/actual for Keychain/EncryptedSharedPreferences
shared/src/iosMain/ auth/ TokenStorage.ios.kt # Keychain-implementasjon (kSecAttrAccessibleAfterFirstUnlock)
shared/src/androidMain/ auth/ TokenStorage.android.kt # Jetpack Security EncryptedSharedPreferencesAuthState
heading.anchorLabelsealed class AuthState { data object Unauthenticated : AuthState() data class Authenticated(val user: AuthUser) : AuthState() data object Loading : AuthState()}Observeres av AuthStateWrapper (Swift @Observable) som gates ImpulseApp mellom LoginView og ContentView.
401-interceptor
heading.anchorLabelApiClient.request() fanger 401-feil:
401 mottatt → tryRefreshToken() → POST /api/v1/auth/refresh ✅ Success → oppdater tokens, retry original request ❌ Failure → authManager.logout() → LoginViewiOS Auth UI
heading.anchorLabelLoginView
heading.anchorLabelVises kun etter logout eller token-expiry (nye brukere ser Onboarding først):
- App-ikon + “Impulse AI”
- “Change your state. Transform your reality.”
- Sign in with Apple-knapp
- Terms of Service / Privacy Policy-lenker
ImpulseApp routing
heading.anchorLabelif !hasCompletedOnboarding → OnboardingView (første lansering)else if !authState.isAuthenticated → LoginView (etter logout)else → ContentView (autentisert)Nøkkelfiler
heading.anchorLabel| Fil | Beskrivelse |
|---|---|
apps/api/src/routes/auth/native.ts | Sign-in og refresh-endepunkter |
apps/api/src/services/impulse-quota-service.ts | isSubscriptionActive() + kvoteberegning |
apps/api/src/middleware/subscription.ts | Subscription-middleware (bruker isSubscriptionActive()) |
apps/api/src/middleware/auth.ts | JWT-validering + dev token bypass |
shared/.../auth/AuthManager.kt | Token lifecycle, authState |
shared/.../auth/OAuthService.kt | Native → API token exchange |
shared/.../api/ApiClient.kt | 401 interceptor med token refresh |
iosApp/Stores/AuthStateWrapper.swift | @Observable bridge for auth state |
iosApp/Features/Auth/LoginView.swift | Sign in with Apple UI |
iosApp/Features/Auth/AppleSignInHandler.swift | Native Apple Sign-In med nonce |
iosApp/ImpulseApp.swift | Auth state gate (login vs content) |
Webhook-flyt (subscription lifecycle)
heading.anchorLabelApp Store Server Notification → POST /webhooks/appstore → DID_RENEW: updateSubscriptionStatus('active') → DID_FAIL_TO_RENEW: updateSubscriptionStatus('billing_retry') → EXPIRED: handleSubscriptionExpiry() → profiles.status = 'expired' → CANCEL: updateSubscriptionStatus('cancelled') → REFUNDED: handleRefund() → profiles.status = 'expired'Samme mønster for Google Play RTDN via /webhooks/googleplay.
Apple Developer Portal — Oppsett
heading.anchorLabelVerdier
heading.anchorLabel| Parameter | Verdi |
|---|---|
| Team ID | VP5P9N253X |
| Bundle ID | com.digiteers.impulseai |
| Key ID (Sign in with Apple) | 3ZNPSK2892 |
| Key-fil | AuthKey_3ZNPSK2892.p8 |
Krav i portalen
heading.anchorLabel- App ID (
com.digiteers.impulseai) — “Sign in with Apple” capability må være aktivert (enabled as primary App ID) - Key — Sign in with Apple-key opprettet og knyttet til App ID-en
- Certificates — Distribution (Digiteers AS) + Development (Jan Fredrik Øveraasen), begge gyldige til 2027/03/22
Apple Client Secret
heading.anchorLabelApple krever en JWT-basert “client secret” for Supabase sin token-verifisering. Genereres fra .p8-nøkkelen:
node scripts/generate-apple-secret.js \ --key-file ~/Downloads/AuthKey_3ZNPSK2892.p8 \ --key-id 3ZNPSK2892 \ --team-id VP5P9N253X \ --client-id com.digiteers.impulseaiGyldighet: 180 dager. Må regenereres ved utløp.
Supabase Apple Provider — Per miljø
heading.anchorLabelApple auth konfigureres forskjellig avhengig av miljø:
| Miljø | Hvor konfigureres | Supabase-prosjekt | Status |
|---|---|---|---|
| Lokal dev | Doppler dev config → env vars → config.toml | Lokal container | ✅ Konfigurert |
| Staging | Supabase Management API | conxpuqktkeiphnnpfiv | ✅ Konfigurert |
| Production | Supabase Management API | jksqfeutntcvukrisrno | ✅ Konfigurert |
Lokal Supabase (config.toml)
heading.anchorLabel[auth.external.apple]enabled = trueclient_id = "env(SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID)"secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"redirect_uri = "im://auth-callback"skip_nonce_check = falseclient_id må være bundle ID (com.digiteers.impulseai) — ikke en Services ID. Native signInWithIdToken setter aud-claimet til bundle ID.
Hosted Supabase (stg/prd)
heading.anchorLabelKonfigurert via Supabase Management API (PATCH /v1/projects/{ref}/config/auth). Endringer kan også gjøres i Supabase Dashboard → Authentication → Providers → Apple.
| Felt | Verdi |
|---|---|
| Client ID | com.digiteers.impulseai |
| Secret Key | JWT fra generate-apple-secret.js (180d gyldighet) |
Ved secret-rotasjon må alle tre miljøer oppdateres: Doppler dev + Supabase API for stg/prd.
StoreKit Testing (Simulator)
heading.anchorLabelKonfigurasjon
heading.anchorLabelConfiguration.storekit definerer sandbox-produkter for lokal testing:
| Produkt | ID | Pris |
|---|---|---|
| Foundation Monthly | im.foundation.monthly | $6.00 |
| Mastery Monthly | im.mastery.monthly | $12.00 |
Innstillinger: _timeRate: 6 (1 time = 1 måned), _developerTeamID: VP5P9N253X.
JWS-validering lokalt
heading.anchorLabelBackend aksepterer Xcode-genererte StoreKit-transaksjoner i dev:
// jws-utils.ts — cert-verifisering kun i productionconst ENFORCE_CERT_VERIFICATION = process.env.NODE_ENV === 'production'
// Fallback: decode uten verifisering for sandbox/testingif (process.env.NODE_ENV === 'production') { throw new Error('Missing x5c certificate chain in JWS')}return JSON.parse(payload) // aksepterer self-signed JWSXcode-verktøy
heading.anchorLabel| Verktøy | Sted | Bruk |
|---|---|---|
| Transaction Manager | Debug → StoreKit → Manage Transactions | Se/slett/expire/refund kjøp |
| Subscription Renewal Rate | Configuration.storekit _timeRate | Akselerert tid (6 = 1t→1mnd) |
| Fail Transactions | Configuration.storekit _failTransactionsEnabled | Test feilscenarier |
E2E Testflyt (Simulator)
heading.anchorLabelForutsetninger
heading.anchorLabel# Start lokal infra med Doppler-injiserte secretsdoppler run --project im --config dev -- pnpm docker:start
# Start API (egen terminal)doppler run --project im --config dev -- pnpm --filter api devTestscenarier
heading.anchorLabel| # | Scenario | Hvordan | Verifiser |
|---|---|---|---|
| 1 | Sign in with Apple | Trykk knapp i Simulator → Apple dialog | Token mottas, profil opprettes, ContentView vises |
| 2 | Trial aktiv | Ny bruker → auto trial (7 dager) | QuotaStore viser trial, alle features tilgjengelig |
| 3 | Trial utløper | DB: UPDATE profiles SET trial_ends_at = NOW() - INTERVAL '1 day' | Paywall vises ved neste API-kall |
| 4 | Kjøp Foundation | PaywallView → velg Foundation → StoreKit dialog | Tier = foundation, kvote = 30/mnd |
| 5 | Maks kvote | DB: Sett used = 30 på impulse_quota | Paywall med upgrade CTA |
| 6 | Kanseller | Xcode → Debug → StoreKit → Cancel Subscription | SubscriptionStatusCard med nedtelling |
| 7 | Subscription utløper | Xcode Transaction Manager → Expire | Paywall vises |
| 8 | Logg ut/inn | ProfileView → Sign out → Sign in again | Tokens slettet → LoginView → ny auth |
Dev Token (API-testing uten Apple Sign-In)
heading.anchorLabelFor å teste tier-spesifikk oppførsel uten kjøp:
# KMP AuthManager har loginWithDevToken() med test-brukere:# trial: 11111111-1111-1111-1111-111111111111# foundation: 22222222-2222-2222-2222-222222222222# mastery: 33333333-3333-3333-3333-333333333333# partner: 44444444-4444-4444-4444-444444444444Sikkerhet
heading.anchorLabel| Aspekt | Tiltak |
|---|---|
| Token-lagring | iOS: Keychain (kSecAttrAccessibleAfterFirstUnlock). Android: EncryptedSharedPreferences. |
| Nonce-verifisering | Apple krever SHA256-hashet nonce for replay-protection. |
| Server-side validering | Supabase verifiserer ID-token direkte med Apple/Google via JWKS. |
| Kort token-levetid | Access token: 1 time. Refresh roterer ved bruk. |
| Ingen passord | Eliminerer credential stuffing, brute-force, phishing. |
| Dev token bypass | Kun når DEV_API_TOKEN er satt OG NODE_ENV !== 'production'. |
| JWS cert-verifisering | Kun i production. Lokalt aksepteres Xcode-genererte transaksjoner. |
| Apple client secret | JWT med 180-dagers gyldighet. Regenerer med scripts/generate-apple-secret.js. |