mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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 --> [](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:
parent
244e7e79f2
commit
c0fefd3b7a
@ -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);
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user