feat(backend): dual-accept hexclave-mobile-oauth-url:// alongside legacy scheme (#1501)

## What

1. **Backend dual-accept**: `isAcceptedNativeAppUrl()` accepts both
`stack-auth-mobile-oauth-url://` (legacy) and
`hexclave-mobile-oauth-url://` (canonical).
2. **Swift SDK switches to the canonical scheme**: `StackAuth` Swift SDK
now emits and intercepts `hexclave-mobile-oauth-url://` for native-app
OAuth callbacks.

Before this PR, `hexclave-mobile-oauth-url` existed only inside
`RENAME-TO-HEXCLAVE.md` — not in any code.

## Why the Swift SDK change is safe

The Swift SDK uses
`ASWebAuthenticationSession(url:callbackURLScheme:completion:)`
([StackClientApp.swift:197-199](sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift#L197)).
With this API, iOS intercepts the callback scheme **ephemerally** — no
`Info.plist` registration is required. The Swift SDK source has no
`Info.plist`, and the example apps' `pbxproj` registers no
`CFBundleURLSchemes`. So:

- New customer builds against the updated SDK → emit new scheme →
backend accepts → `ASWebAuthenticationSession` intercepts on new scheme
→ works.
- Already-shipped customer App Store binaries on older SDK versions →
emit old scheme → backend still accepts → works.
- **No customer ever has to update an `Info.plist`.**

The only real backward-compat constraint is that the backend can never
drop the old scheme (already-shipped customer binaries have the constant
baked into them). Hence the dual-accept.

(Note: `RENAME-TO-HEXCLAVE.md` line 88 incorrectly attributes the
constraint to `Info.plist` registration. That's not how the SDK works —
the scheme is baked into the SDK binary, not the customer's plist. The
fix described in that doc is essentially the right shape; only the
mechanism description is wrong.)

## Changes

| File | Change |
|---|---|
| `packages/stack-shared/src/utils/redirect-urls.tsx` |
`isAcceptedNativeAppUrl()` accepts either protocol. |
| `apps/backend/src/lib/redirect-urls.test.tsx` | Adds positive
assertions for the new scheme in `isAcceptedNativeAppUrl`; parity
negative assertions in `validateRedirectUrl`. |
| `sdks/implementations/swift/Sources/StackAuth/StackClientApp.swift` |
`callbackScheme` → `"hexclave-mobile-oauth-url"`; fatalError example
strings updated. |
| `sdks/implementations/swift/Tests/StackAuthTests/OAuthTests.swift` |
Test fixture URLs updated (no assertions depend on the scheme literal).
|
|
`sdks/implementations/swift/Examples/StackAuthiOS/.../StackAuthiOSApp.swift`
| Default values in the example UI. |
|
`sdks/implementations/swift/Examples/StackAuthMacOS/.../StackAuthMacOSApp.swift`
| Default values in the example UI. |
| `sdks/implementations/swift/README.md` | Documents the new canonical
scheme; compat note for the legacy one. |
| `sdks/spec/src/apps/client-app.spec.md` | New scheme is canonical;
legacy is "accepted indefinitely for already-shipped customer app
binaries built against older SDK versions." |

## Verification

- `pnpm test run apps/backend/src/lib/redirect-urls.test.tsx` — 34/34
passing (was 33; one new `it` block plus parity assertions).
- `pnpm --filter @stackframe/stack-shared --filter @stackframe/backend
run lint` — clean.
- `pnpm --filter @stackframe/stack-shared --filter @stackframe/backend
run typecheck` — clean.
- Swift assertions in `OAuthTests.swift` do not check the scheme literal
— they only check `oauth/authorize/<provider>`, state/verifier
non-emptiness, and that `redirectUrl` round-trips. The fixture-value
change is mechanical.

## Risk

Low. Backend behavior strictly widens (every URL accepted before is
still accepted). Swift SDK change is internal to OAuth callback
handling, requires no customer migration, and is paired with the backend
dual-accept landing in the same PR.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Adopted the canonical OAuth callback scheme
"hexclave-mobile-oauth-url://" for native apps while continuing to
accept the legacy "stack-auth-mobile-oauth-url://".

* **Documentation**
* Updated SDK docs, examples, and spec guidance to reference the
canonical callback scheme and clarify legacy acceptance.

* **Tests & Samples**
* Updated tests and example apps to use and validate the canonical
scheme.

* **Style**
* Rebranded the dev-tool trigger icon to the new Hexclave monochrome
logo.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1501?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-05-27 15:44:06 -07:00 committed by GitHub
parent 244e7e79f2
commit c0fefd3b7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 36 additions and 20 deletions

View File

@ -612,6 +612,9 @@ describe('validateRedirectUrl', () => {
expect(validateRedirectUrl('stack-auth-mobile-oauth-url://success', tenancy)).toBe(false);
expect(validateRedirectUrl('stack-auth-mobile-oauth-url://error', tenancy)).toBe(false);
expect(validateRedirectUrl('stack-auth-mobile-oauth-url://oauth-callback', tenancy)).toBe(false);
expect(validateRedirectUrl('hexclave-mobile-oauth-url://success', tenancy)).toBe(false);
expect(validateRedirectUrl('hexclave-mobile-oauth-url://error', tenancy)).toBe(false);
expect(validateRedirectUrl('hexclave-mobile-oauth-url://oauth-callback', tenancy)).toBe(false);
});
it('should not accept other custom schemes without trusted domain config', () => {
@ -631,15 +634,23 @@ describe('validateRedirectUrl', () => {
});
describe('isAcceptedNativeAppUrl', () => {
it('should accept the native app OAuth URL scheme', () => {
it('should accept the legacy native app OAuth URL scheme', () => {
expect(isAcceptedNativeAppUrl('stack-auth-mobile-oauth-url://success')).toBe(true);
expect(isAcceptedNativeAppUrl('stack-auth-mobile-oauth-url://error')).toBe(true);
});
it('should accept the canonical Hexclave native app OAuth URL scheme', () => {
expect(isAcceptedNativeAppUrl('hexclave-mobile-oauth-url://success')).toBe(true);
expect(isAcceptedNativeAppUrl('hexclave-mobile-oauth-url://error')).toBe(true);
expect(isAcceptedNativeAppUrl('hexclave-mobile-oauth-url://oauth-callback')).toBe(true);
});
it('should reject other custom schemes', () => {
expect(isAcceptedNativeAppUrl('myapp://callback')).toBe(false);
expect(isAcceptedNativeAppUrl('stackauth-myapp://callback')).toBe(false);
expect(isAcceptedNativeAppUrl('stack-auth://callback')).toBe(false);
expect(isAcceptedNativeAppUrl('hexclave://callback')).toBe(false);
expect(isAcceptedNativeAppUrl('hexclave-mobile-oauth-url-extra://callback')).toBe(false);
expect(isAcceptedNativeAppUrl('https://example.com/callback')).toBe(false);
expect(isAcceptedNativeAppUrl('http://localhost:3000/callback')).toBe(false);
});

View File

@ -160,7 +160,9 @@ export function isAcceptedNativeAppUrl(urlOrString: string): boolean {
const url = createUrlIfValid(urlOrString);
if (!url) return false;
return url.protocol === 'stack-auth-mobile-oauth-url:';
// Legacy scheme accepted indefinitely; baked into already-shipped Swift SDK binaries.
return url.protocol === 'stack-auth-mobile-oauth-url:'
|| url.protocol === 'hexclave-mobile-oauth-url:';
}
export function validateRedirectUrl(

View File

@ -75,7 +75,10 @@ const DEFAULT_STATE: DevToolState = {
panelHeight: 520,
};
const STACK_LOGO_SVG = '<svg width="14" height="17" viewBox="0 0 131 156" fill="currentColor"><path d="M124.447 28.6459L70.1382 1.75616C67.3472 0.374284 64.0715 0.372197 61.279 1.75051L0.740967 31.6281V87.6369L65.7101 119.91L117.56 93.675V112.414L65.7101 138.44L0.740967 106.584V119.655C0.740967 122.359 2.28151 124.827 4.71097 126.015L62.282 154.161C65.0966 155.538 68.3938 155.515 71.1888 154.099L130.47 124.074V79.7105C130.47 74.8003 125.34 71.5769 120.915 73.7077L79.4531 93.675V75.9771L130.47 50.1589V38.3485C130.47 34.2325 128.137 30.4724 124.447 28.6459Z"/></svg>';
// Hexclave mark — hexagon outline with three radial bars, monochrome via currentColor
// so it inherits the trigger logo's color. Sourced from apps/dashboard/public/hexclave-icon.svg
// (gradient + glow stripped; this is a tiny trigger glyph, not the full brand mark).
const HEXCLAVE_LOGO_SVG = '<svg width="16" height="16" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="miter"><path d="M 24 4 L 41.32 14 L 41.32 34 L 24 44 L 6.68 34 L 6.68 14 Z"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none" transform="rotate(120 24 24)"/><path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="currentColor" stroke="none" transform="rotate(240 24 24)"/></svg>';
// ---------------------------------------------------------------------------
// State management
@ -454,7 +457,7 @@ function createTrigger(onClick: () => void): { element: HTMLElement; cleanup: ()
title: 'Hexclave Dev Tools',
});
const logoSpan = h('span', { className: 'sdt-trigger-logo' });
setHtml(logoSpan, STACK_LOGO_SVG);
setHtml(logoSpan, HEXCLAVE_LOGO_SVG);
btn.appendChild(logoSpan);
let placement = loadPlacement() ?? { corner: 'bottom-right' as TriggerCorner };

View File

@ -1238,8 +1238,8 @@ class MacOSPresentationContextProvider: NSObject, ASWebAuthenticationPresentatio
struct OAuthView: View {
@Bindable var viewModel: SDKTestViewModel
@State private var provider = "google"
@State private var redirectUrl = "stack-auth-mobile-oauth-url://success"
@State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error"
@State private var redirectUrl = "hexclave-mobile-oauth-url://success"
@State private var errorRedirectUrl = "hexclave-mobile-oauth-url://error"
@State private var isSigningIn = false
private let presentationProvider = MacOSPresentationContextProvider()

View File

@ -1252,8 +1252,8 @@ struct ContactChannelsView: View {
struct OAuthView: View {
@Bindable var viewModel: SDKTestViewModel
@State private var provider = "google"
@State private var redirectUrl = "stack-auth-mobile-oauth-url://success"
@State private var errorRedirectUrl = "stack-auth-mobile-oauth-url://error"
@State private var redirectUrl = "hexclave-mobile-oauth-url://success"
@State private var errorRedirectUrl = "hexclave-mobile-oauth-url://error"
@State private var isSigningIn = false
private let presentationProvider = iOSPresentationContextProvider()

View File

@ -88,20 +88,20 @@ Two approaches for OAuth authentication:
```swift
// Opens auth session, handles callback automatically
// Uses fixed callback scheme: stack-auth-mobile-oauth-url://
// Uses fixed callback scheme: hexclave-mobile-oauth-url://
try await stack.signInWithOAuth(provider: "google")
```
**2. Manual URL handling** - For custom implementations:
> **Note:** The `stack-auth-mobile-oauth-url://` scheme is automatically accepted.
> **Note:** The `hexclave-mobile-oauth-url://` scheme is automatically accepted (the legacy `stack-auth-mobile-oauth-url://` scheme also remains accepted for backwards compatibility).
```swift
// Get the OAuth URL (must provide absolute URLs)
let oauth = try await stack.getOAuthUrl(
provider: "google",
redirectUrl: "stack-auth-mobile-oauth-url://success",
errorRedirectUrl: "stack-auth-mobile-oauth-url://error"
redirectUrl: "hexclave-mobile-oauth-url://success",
errorRedirectUrl: "hexclave-mobile-oauth-url://error"
)
// Open oauth.url in your own browser/webview

View File

@ -131,10 +131,10 @@ public actor StackClientApp {
) async throws -> OAuthUrlResult {
// Validate that URLs are absolute URLs (panic if not - these are programmer errors)
guard redirectUrl.contains("://") else {
fatalError("redirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://success')")
fatalError("redirectUrl must be an absolute URL (e.g., 'hexclave-mobile-oauth-url://success')")
}
guard errorRedirectUrl.contains("://") else {
fatalError("errorRedirectUrl must be an absolute URL (e.g., 'stack-auth-mobile-oauth-url://error')")
fatalError("errorRedirectUrl must be an absolute URL (e.g., 'hexclave-mobile-oauth-url://error')")
}
let actualState = state ?? generateRandomString(length: 32)
@ -186,7 +186,7 @@ public actor StackClientApp {
return
}
let callbackScheme = "stack-auth-mobile-oauth-url"
let callbackScheme = "hexclave-mobile-oauth-url"
let oauth = try await getOAuthUrl(
provider: provider,
redirectUrl: callbackScheme + "://success",

View File

@ -6,8 +6,8 @@ import Foundation
struct OAuthTests {
// Default test URLs (must be absolute URLs)
let testRedirectUrl = "stack-auth-mobile-oauth-url://success"
let testErrorRedirectUrl = "stack-auth-mobile-oauth-url://error"
let testRedirectUrl = "hexclave-mobile-oauth-url://success"
let testErrorRedirectUrl = "hexclave-mobile-oauth-url://error"
// MARK: - OAuth URL Generation Tests

View File

@ -66,7 +66,7 @@ Note: Additional provider scopes are configured via oauthScopesOnSignIn construc
Implementation:
1. Construct full redirect URLs using a fixed callback scheme:
- Native apps: "stack-auth-mobile-oauth-url://success" and "stack-auth-mobile-oauth-url://error"
- Native apps: "hexclave-mobile-oauth-url://success" and "hexclave-mobile-oauth-url://error"
- Browser: Use the configured OAuth callback handler URL as redirect_uri and window.location to construct absolute URLs
- Browser: If options.returnTo is provided, pass it as afterCallbackRedirectUrl, not as redirect_uri
@ -82,7 +82,7 @@ Implementation:
4. Open the authorization URL:
- Browser: perform redirect according to redirectMethod
- iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "stack-auth-mobile-oauth-url"
- iOS/macOS: ASWebAuthenticationSession with callbackURLScheme: "hexclave-mobile-oauth-url"
- Android: Custom Tabs with callback URL registered as deep link
- Desktop: Open system browser with registered URL scheme for callback
@ -166,7 +166,7 @@ Returns: { url: string, state: string, codeVerifier: string, redirectUrl: string
redirectUrl: The redirect URL (same as input, needed for token exchange - must match exactly)
Note on URL schemes:
- The "stack-auth-mobile-oauth-url://" scheme is automatically accepted by the backend without any configuration.
- The "hexclave-mobile-oauth-url://" scheme is automatically accepted by the backend without any configuration.
Implementation:
1. Generate or use provided state and codeVerifier