Skip to content

feat: add optional WorkOS sign-in with per-account pairings - #37

Merged
lemarier merged 3 commits into
mainfrom
lemarier/optional-workos-sign-in-that-never-gates-local-u
Sep 25, 2026
Merged

lemarier merged 3 commits into
mainfrom
lemarier/optional-workos-sign-in-that-never-gates-local-u

Conversation

@lemarier

@lemarier lemarier commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Change

Part of #33. Sign-in is optional and never gates setup. The account button on the setup screen opens AuthKit in ASWebAuthenticationSession (ephemeral). The app is a public client with PKCE (S256) and checks state; no secret ships. The redirect com.origin89.apps.ios://auth/callback is a constant, independent of the bundle identifier. Account.xcconfig sets the client ID: Debug uses staging, Release uses production.

  • Tokens. The WorkOS session is kept in the Keychain (WhenUnlockedThisDeviceOnly, not synchronizable). Account.accessToken() refreshes within 60 s of exp, runs one refresh at a time because WorkOS rotates the refresh token, and keeps the rotated token. Only an invalid_grant answer ends the session, and only once the Keychain no longer holds it; the person is told. A timeout, rate limit, other client error or unreachable WorkOS keeps the session. A refresh that finishes after sign-out is dropped.
  • Pairings per account, as decided for Optional WorkOS sign-in that never gates local use #33. Pairings made while signed out stay visible in every state. Pairings made while signed in go to a Keychain service for that account (com.origin89.setup.enrolment/<user id>) and show only while that account is signed in. Sign-out and switching accounts delete or change no enrolment. The app suspends the current flow, closing its connection, and starts one for the new owner, each with its own last controller. Forgetting while signed in removes the account's pairing and the signed-out one for that controller; other accounts' pairings stay.
  • New install. The existing cleanup now removes enrolments under every account and the kept session, since Keychain items outlive an app delete.

On #33's open question: WorkOS documents Apple sign-in only through the AuthKit web sheet (provider=authkit or AppleOAuth). There is no way to exchange a token from Apple's native button, so this uses the sheet, which satisfies guideline 4.8.

Linking controllers to a site (#35) and in-app account deletion (#36) wait for origin89hq/cloud#4 to deploy. Linking also needs the core to expose the enrolment epoch. Sign-out removes the local session only; it does not revoke the WorkOS session server-side.

Validation

just check passes: swift-format, Rust fmt/clippy/tests, 167 SetupKit tests, the simulator app build and the bench build. New tests:

  • Sign-in: success (the verifier sent hashes to the challenge in the sheet URL, no client secret); cancel; a forged state; an error redirect; a code WorkOS refuses; unreadable JSON, 503 and 429; a Keychain that refuses the save; a build without a client ID. PKCE is checked against the RFC 7636 appendix B vector.
  • Refresh: an unexpired token (no request); a refresh with the rotated token kept; an unreadable token treated as expired; invalid_grant (session ended, Keychain cleared); invalid_grant with a Keychain that keeps the session (still signed in); 408, 429, invalid_request, invalid_client and a bare 403 (session kept); offline and 502 (session kept); concurrent callers (one request); sign-out during a refresh.
  • Sign-out: session removed; a Keychain failure keeps the account signed in.
  • Pairings: an account's pairing is hidden after sign-out and resumes after sign-in, with nothing removed; a signed-out pairing is used by an account; forgetting leaves other accounts' pairings; a failed removal still removes the other store's pairing.

Checked against WorkOS: the authorize URL the app builds, with the staging and production client IDs, redirects to each environment's AuthKit bootstrap; a wrong redirect goes to redirect-uri-invalid. Not done yet: a sign-in on a device against staging (#33's acceptance). The Keychain stores have no unit tests, like the existing KeychainEnrolmentStore; the tests use in-memory stores in their place.

Copilot AI lite review requested due to automatic review settings September 25, 2026 14:19
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The iOS app adds WorkOS sign-in, account session storage and refresh, and account-specific controller enrolments. The setup flow uses the current account owner and refreshes account state or replaces flow state when the owner changes. A new account screen provides sign-in and sign-out actions. Build settings supply separate staging and production client IDs. Tests cover authentication, token refresh, enrolment storage, and flow resumption.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: 🟡 Moderate · up to 10b00

Setup remains available while signed out, but users cannot complete WorkOS sign-in or use account-scoped pairings. Register the callback scheme before merging this feature.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 10b00

Rapid account changes can leave the setup screen using a previous account’s controller pairings. The exposure is local to the app and device, but it undermines the new account-isolation behavior. No broader remote attack path was established.

Retained concerns

  • Medium · security · inferred: Out-of-order owner-change tasks can install a setup flow scoped to a previous account after sign-out or account switching, making that account’s saved controller pairing available to the current flow.
Security review details

Security Blast Radius

  • inferred — The identified exposure is to someone able to trigger account changes in the local app. A stale flow can look up a previous account’s controller pairing and attempt to reconnect; the evidence does not establish remote reachability or access to other devices’ Keychains.

Security Findings and Attack Paths

  • inferred — If ownership changes again while a suspension is awaiting completion, an older task can finish last and install its captured owner’s flow under the current identity. The stale flow can then resume using that owner’s saved pairing.

Trust Boundaries and Controls

  • observed — Callback state and PKCE constrain the authentication exchange. Account generation checks discard refresh results after sign-out, and flow suspension closes its connection; none validates the owner when an asynchronous replacement flow is assigned.

Resilience and Maintainability Implications

  • observed — A failed Keychain save after successful token rotation leaves the refreshed session in memory but may require sign-in after restart; the code logs that persistence failure rather than discarding the live session.

Hardening Proposals

  • proposed — Serialize owner transitions or check that the captured owner is still current before installing a replacement flow; ensure an obsolete transition cannot reconnect a stale flow.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 79 functions across 11 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: optional WorkOS sign-in with account-scoped pairings.
Description check ✅ Passed The description follows the required Change and Validation sections. It explains the behavior, security constraints, account-scoped pairing rules, validation results, unperformed device and Keychain c…
  • Fix all pre-merge checks with AI

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (3)
apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift-152-155 (1)

152-155: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Classify refresh failures by the OAuth error field.

AuthKitClient.authenticate maps 408, invalid_request, and invalid_client responses to .refused. Account.refresh then calls endSession(), removes the Keychain session, and forces sign-in. WorkOS classifies 408 as transient. OAuth invalid_request and invalid_client do not establish refresh-token revocation.

Parse the response body's error field. Return .refused only for invalid_grant. Keep the session for 408 and other non-terminal failures. Do not treat bodyless 401 or 403 as revocation without an explicit WorkOS contract that defines them that way. Add fixtures for these responses and assert that the session remains stored.

apps/ios/SetupKit/Sources/SetupKit/Account.swift-224-235 (1)

224-235: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Only report signed out after Keychain removal succeeds.

When a refused refresh reaches endSession(), a failed store.remove() is ignored. The method then clears memory and reports .signedOut, although the Keychain can still contain the revoked session. A later launch loads that session as .signedIn and retries it.

Propagate the removal error and update the in-memory state only after removal succeeds. Keep the HTTP refusal classification unchanged.

Suggested fix
-      if error == .refused { endSession() }
+      if error == .refused { try endSession() }
       throw error
     }
   }

-  private func endSession() {
+  private func endSession() throws(AccountError) {
     SetupLog.account.notice("WorkOS refused the refresh token: the session ended")
-    do {
-      try store.remove()
-    } catch {
-      SetupLog.account.error("the ended session could not be removed from the Keychain")
-    }
+    try store.remove()
     generation += 1
apps/ios/SetupKit/Sources/SetupKit/Account.swift-206-222 (1)

206-222: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Do not return a refreshed session when persistence fails.

store.save(refreshed) can fail before removing the old session. The current branch then keeps self.session, reports .signedIn, and returns refreshed. Clearing only the in-memory state would not remove the old persisted token, so the next launch could load that spent token as signed in.

Retry saving refreshed first. If the retry fails, clear the in-memory session, remove the persisted session, and throw the persistence error. This preserves the rotated token when the retry succeeds and removes the stale token only after persistence has failed again.

Suggested fix
-      } catch {
-        // The kept refresh token is spent: the next launch signs in again.
-        SetupLog.account.error("the refreshed session could not be kept")
+      } catch let persistenceError {
+        do {
+          try store.save(refreshed)
+        } catch {
+          self.session = nil
+          status = .signedOut
+          try store.remove()
+          SetupLog.account.error("the refreshed session could not be kept")
+          throw persistenceError
+        }
       }
       return refreshed

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: c126b171-8804-4bd7-bb50-a46abebcd836

📥 Commits

Reviewing files that changed from the base of the PR and between b77d40f and be654bc.

📒 Files selected for processing (15)
  • apps/ios/Account.xcconfig
  • apps/ios/Origin89.xcodeproj/project.pbxproj
  • apps/ios/Origin89/AccountView.swift
  • apps/ios/Origin89/Info.plist
  • apps/ios/Origin89/Origin89App.swift
  • apps/ios/Origin89/SetupView.swift
  • apps/ios/SetupKit/Sources/SetupKit/Account.swift
  • apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift
  • apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift
  • apps/ios/SetupKit/Sources/SetupKit/KeychainAccountSessionStore.swift
  • apps/ios/SetupKit/Sources/SetupKit/KeychainEnrolmentStore.swift
  • apps/ios/SetupKit/Sources/SetupKit/SetupLog.swift
  • apps/ios/SetupKit/Tests/SetupKitTests/AccountEnrolmentTests.swift
  • apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift
  • apps/ios/Signing.xcconfig

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift (1)

269-277: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the expected error for each HTTP response.

The any Error assertion only checks that accessToken() throws. It can miss incorrect error classification. Assert .unavailable for 408 and 429, and .invalidResponse for the other listed responses. Keep the session-retention assertions.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: a675d111-b20e-483c-8170-7f6e28db2fb0

📥 Commits

Reviewing files that changed from the base of the PR and between be654bc and 5c01ec4.

📒 Files selected for processing (3)
  • apps/ios/SetupKit/Sources/SetupKit/Account.swift
  • apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift
  • apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Register the WorkOS callback scheme. · Info.plist:5-6

apps/ios/Origin89/Info.plist:5-6
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Register the WorkOS callback scheme.

When a configured signed-out user taps Sign in, AccountView passes com.origin89.apps.ios to ASWebAuthenticationSession. The app target does not register this scheme in Info.plist or its build settings. iOS therefore cannot route the WorkOS redirect back to the app, so sign-in cannot continue to the code exchange.

🐛 Suggested fix
 <dict>
+	<key>CFBundleURLTypes</key>
+	<array>
+		<dict>
+			<key>CFBundleURLSchemes</key>
+			<array>
+				<string>com.origin89.apps.ios</string>
+			</array>
+		</dict>
+	</array>
 	<key>WorkOSClientID</key>

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Advanced

Run ID: 6e9a0c19-2b6c-42d9-9e24-d9205814ecfc

📥 Commits

Reviewing files that changed from the base of the PR and between 5c01ec4 and 10b00c2.

📒 Files selected for processing (1)
  • apps/ios/SetupKit/Tests/SetupKitTests/AccountTests.swift

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.

@lemarier

Copy link
Copy Markdown
Contributor Author

@coderabbitai re Register the WorkOS callback scheme (Info.plist, outside the diff): not changing this. ASWebAuthenticationSession catches the redirect itself when the scheme is passed as callbackURLScheme (the bare scheme, no ://), which AccountView does, so a CFBundleURLTypes entry isn't needed. Registering it would only let other apps and pages open Origin89 through that URL, which nothing here handles. The device sign-in against staging (#33's acceptance) will confirm it.

@lemarier
lemarier merged commit 91a9c09 into main Sep 25, 2026
2 checks passed
@lemarier
lemarier deleted the lemarier/optional-workos-sign-in-that-never-gates-local-u branch September 25, 2026 15:28
@coderabbitai

coderabbitai Bot commented Sep 25, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
ast-grep outline apps/ios/Origin89/AccountView.swift --items all
rg -n -C4 'ASWebAuthenticationSession|callbackURLScheme|CFBundleURLTypes|WorkOSClientID' apps/ios/Origin89/AccountView.swift apps/ios/Origin89/Info.plist apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift

Length of output: 2125


🤖 web_search executed:

Apple developer documentation ASWebAuthenticationSession callbackURLScheme custom scheme callback app need CFBundleURLTypes register URL scheme? ASWebAuthenticationSession callback URL custom scheme not registered Info.plist

Length of output: 5580


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ast-grep outline apps/ios/SetupKit/Sources/SetupKit/Account.swift --match 'signIn|Account' --view expanded
ast-grep outline apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift --match 'AuthKitConfiguration|callbackScheme|redirectURI' --view expanded
rg -n -C5 'callbackScheme|redirectURI|authenticate\(' apps/ios/SetupKit/Sources/SetupKit/Account.swift apps/ios/SetupKit/Sources/SetupKit/AuthKitClient.swift

Length of output: 10630


@lemarier, you’re right. Account.signIn passes the bare scheme to AccountView, which supplies it as callbackURLScheme. ASWebAuthenticationSession receives the callback for that session, so this flow does not need a CFBundleURLTypes entry. I withdraw my comment. The staging device test remains a useful end-to-end check.


✏️ Learnings added
Learnt from: lemarier
URL: https://github.com/origin89hq/apps/pull/37

Timestamp: 2026-09-25T15:28:37.504Z
Learning: In the Origin89 iOS WorkOS sign-in flow, `apps/ios/SetupKit/Sources/SetupKit/Account.swift` passes the bare `AuthKitConfiguration.callbackScheme` (`com.origin89.apps.ios`) to `apps/ios/Origin89/AccountView.swift`, which supplies it as `callbackURLScheme` to `ASWebAuthenticationSession`. This session handles the authentication callback without a `CFBundleURLTypes` registration in `apps/ios/Origin89/Info.plist`; the app does not use a general URL-opening handler for this callback.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants