mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
## 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 -->
185 lines
8.2 KiB
Swift
185 lines
8.2 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import StackAuth
|
|
|
|
@Suite("OAuth Tests")
|
|
struct OAuthTests {
|
|
|
|
// Default test URLs (must be absolute URLs)
|
|
let testRedirectUrl = "hexclave-mobile-oauth-url://success"
|
|
let testErrorRedirectUrl = "hexclave-mobile-oauth-url://error"
|
|
|
|
// MARK: - OAuth URL Generation Tests
|
|
|
|
@Test("Should generate OAuth URL for Google")
|
|
func generateOAuthUrlForGoogle() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("oauth/authorize/google"))
|
|
#expect(!result.state.isEmpty)
|
|
#expect(!result.codeVerifier.isEmpty)
|
|
}
|
|
|
|
@Test("Should generate OAuth URL for GitHub")
|
|
func generateOAuthUrlForGitHub() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "github", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("oauth/authorize/github"))
|
|
#expect(!result.state.isEmpty)
|
|
#expect(!result.codeVerifier.isEmpty)
|
|
}
|
|
|
|
@Test("Should generate OAuth URL for Microsoft")
|
|
func generateOAuthUrlForMicrosoft() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "microsoft", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("oauth/authorize/microsoft"))
|
|
#expect(!result.state.isEmpty)
|
|
#expect(!result.codeVerifier.isEmpty)
|
|
}
|
|
|
|
@Test("Should include project ID in OAuth URL")
|
|
func oauthUrlIncludesProjectId() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("client_id=\(testProjectId)"))
|
|
}
|
|
|
|
@Test("Should use sentinel client_secret when publishable key is missing")
|
|
func oauthUrlUsesSentinelWhenPublishableKeyMissing() async throws {
|
|
let app = TestConfig.createClientApp(publishableClientKey: nil)
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("client_secret=\(publishableClientKeyNotNecessarySentinel)"))
|
|
}
|
|
|
|
@Test("Should use publishable key when available for OAuth client_secret")
|
|
func oauthUrlUsesPublishableKeyWhenAvailable() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.url.absoluteString.contains("client_secret=\(testPublishableClientKey)"))
|
|
}
|
|
|
|
@Test("Should resolve OAuth client secret from API client")
|
|
func apiClientResolvesOAuthClientSecret() async throws {
|
|
let appWithKey = TestConfig.createClientApp()
|
|
let appWithoutKey = TestConfig.createClientApp(publishableClientKey: nil)
|
|
|
|
let secretWithKey = await appWithKey.client.getOAuthClientSecret()
|
|
let secretWithoutKey = await appWithoutKey.client.getOAuthClientSecret()
|
|
|
|
#expect(secretWithKey == testPublishableClientKey)
|
|
#expect(secretWithoutKey == publishableClientKeyNotNecessarySentinel)
|
|
}
|
|
|
|
@Test("Should include state in OAuth URL")
|
|
func oauthUrlIncludesState() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
// URL should contain the state parameter
|
|
#expect(result.url.absoluteString.contains("state="))
|
|
}
|
|
|
|
@Test("Should generate PKCE code verifier")
|
|
func generatesPkceCodeVerifier() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
// Code verifier should be long enough for security (43-128 chars for PKCE)
|
|
#expect(result.codeVerifier.count >= 43)
|
|
}
|
|
|
|
@Test("Should generate unique state for each call")
|
|
func generatesUniqueState() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result1 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result1.state != result2.state)
|
|
}
|
|
|
|
@Test("Should generate unique code verifier for each call")
|
|
func generatesUniqueCodeVerifier() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result1 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
let result2 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result1.codeVerifier != result2.codeVerifier)
|
|
}
|
|
|
|
@Test("Should handle case-insensitive provider name")
|
|
func caseInsensitiveProvider() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result1 = try await app.getOAuthUrl(provider: "Google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
let result2 = try await app.getOAuthUrl(provider: "GOOGLE", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
let result3 = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
// All should generate valid URLs with google provider
|
|
#expect(result1.url.absoluteString.contains("oauth/authorize/google"))
|
|
#expect(result2.url.absoluteString.contains("oauth/authorize/google"))
|
|
#expect(result3.url.absoluteString.contains("oauth/authorize/google"))
|
|
}
|
|
|
|
@Test("Should include code challenge in URL")
|
|
func includesCodeChallenge() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
// URL should contain PKCE code challenge
|
|
#expect(result.url.absoluteString.contains("code_challenge="))
|
|
#expect(result.url.absoluteString.contains("code_challenge_method=S256"))
|
|
}
|
|
|
|
// MARK: - Redirect URL Tests
|
|
// Note: Invalid URL validation (missing scheme) now panics and cannot be tested
|
|
|
|
@Test("Should return the exact redirect URL provided")
|
|
func returnsExactRedirectUrl() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: testRedirectUrl, errorRedirectUrl: testErrorRedirectUrl)
|
|
|
|
#expect(result.redirectUrl == testRedirectUrl)
|
|
}
|
|
|
|
@Test("Should accept https URLs")
|
|
func acceptsHttpsUrls() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
let httpsUrl = "https://myapp.com/callback"
|
|
let httpsErrorUrl = "https://myapp.com/error"
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: httpsUrl, errorRedirectUrl: httpsErrorUrl)
|
|
|
|
#expect(result.redirectUrl == httpsUrl)
|
|
}
|
|
|
|
@Test("Should accept custom scheme URLs")
|
|
func acceptsCustomSchemeUrls() async throws {
|
|
let app = TestConfig.createClientApp()
|
|
let customUrl = "myapp://oauth/callback"
|
|
let customErrorUrl = "myapp://error"
|
|
|
|
let result = try await app.getOAuthUrl(provider: "google", redirectUrl: customUrl, errorRedirectUrl: customErrorUrl)
|
|
|
|
#expect(result.redirectUrl == customUrl)
|
|
}
|
|
}
|