diff --git a/config/apps.yaml b/config/apps.yaml index dfaacfbb..426064d4 100644 --- a/config/apps.yaml +++ b/config/apps.yaml @@ -78,10 +78,18 @@ apps: spoo-raycast: name: Raycast Extension icon: raycast.svg - description: Shorten links from Raycast + description: Shorten and manage your spoo.me links from Raycast verified: true - status: coming_soon + status: live type: device_auth + redirect_uris: + - https://raycast.com/redirect?* + links: + raycast: https://www.raycast.com/spoo-me/spoo + permissions: + - Access your spoo.me account + - Create and manage short URLs + - View and export your analytics spoo-vscode: name: VS Code Extension diff --git a/routes/auth/device.py b/routes/auth/device.py index 59c590c7..f1612742 100644 --- a/routes/auth/device.py +++ b/routes/auth/device.py @@ -67,11 +67,15 @@ def _device_error(request: Request, error: str, status_code: int = 400) -> Respo def _build_callback_redirect( - code: str, state: str, redirect_uri: str, app: AppEntry + code: str, + state: str, + redirect_uri: str, + app: AppEntry, + svc: DeviceAuthSvc, ) -> RedirectResponse: """Build the redirect to the callback page or a registered redirect_uri.""" params = urlencode({"code": code, "state": state}) - if redirect_uri and redirect_uri in app.redirect_uris: + if redirect_uri and svc.validate_redirect_uri(redirect_uri, app): separator = "&" if "?" in redirect_uri else "?" return RedirectResponse(f"{redirect_uri}{separator}{params}", status_code=302) return RedirectResponse(f"/auth/device/callback?{params}", status_code=302) @@ -122,7 +126,9 @@ async def device_login( code = await device_auth_service.create_device_auth_code( profile.id, profile.email, app_id=app_id ) - return _build_callback_redirect(code, state, redirect_uri, app) + return _build_callback_redirect( + code, state, redirect_uri, app, device_auth_service + ) # No grant: show consent screen csrf_token = generate_secure_token(_CSRF_TOKEN_BYTES) @@ -193,7 +199,9 @@ async def device_consent_approve( ) # Clear CSRF cookie and redirect - response = _build_callback_redirect(code, state, redirect_uri, app) + response = _build_callback_redirect( + code, state, redirect_uri, app, device_auth_service + ) response.delete_cookie(_CSRF_COOKIE_NAME) return response diff --git a/services/auth/device.py b/services/auth/device.py index ba678be5..a402e0f8 100644 --- a/services/auth/device.py +++ b/services/auth/device.py @@ -67,8 +67,22 @@ def resolve_app(self, app_id: str) -> AppEntry | None: return entry if entry and entry.is_live_device_app() else None def validate_redirect_uri(self, redirect_uri: str, app: AppEntry) -> bool: - """Return True if redirect_uri is empty or in the app's allowlist.""" - return not redirect_uri or redirect_uri in app.redirect_uris + """Return True if redirect_uri is empty, exact-matches an allowlist + entry, or prefix-matches an entry ending with ``*``. + + The ``*`` suffix is used for OAuth clients (e.g. Raycast) that append + a varying query string to a fixed redirect URL. Only exact or + explicit-wildcard entries are accepted; implicit wildcards are not. + """ + if not redirect_uri: + return True + for allowed in app.redirect_uris: + if allowed.endswith("*"): + if redirect_uri.startswith(allowed[:-1]): + return True + elif redirect_uri == allowed: + return True + return False async def create_device_auth_code( self, user_id: ObjectId, email: str, app_id: str | None = None diff --git a/tests/integration/test_device_auth.py b/tests/integration/test_device_auth.py index f4db93ee..481932c2 100644 --- a/tests/integration/test_device_auth.py +++ b/tests/integration/test_device_auth.py @@ -107,7 +107,13 @@ def device_auth_svc(): # resolve_app and validate_redirect_uri are sync — must not be coroutines svc.resolve_app = MagicMock(side_effect=_resolve_app) svc.validate_redirect_uri = MagicMock( - side_effect=lambda uri, app: not uri or uri in app.redirect_uris + side_effect=lambda uri, app: ( + not uri + or any( + uri.startswith(a[:-1]) if a.endswith("*") else uri == a + for a in app.redirect_uris + ) + ) ) return svc diff --git a/tests/unit/services/test_redirect_uri.py b/tests/unit/services/test_redirect_uri.py new file mode 100644 index 00000000..af6a621f --- /dev/null +++ b/tests/unit/services/test_redirect_uri.py @@ -0,0 +1,77 @@ +"""Unit tests for wildcard redirect URI matching.""" + +from __future__ import annotations + +from schemas.models.app import AppEntry +from services.auth.device import DeviceAuthService + + +def _app(redirect_uris: list[str]) -> AppEntry: + return AppEntry(name="test", description="test app", redirect_uris=redirect_uris) + + +class TestValidateRedirectUri: + """Tests for DeviceAuthService.validate_redirect_uri.""" + + def setup_method(self): + self.svc = DeviceAuthService.__new__(DeviceAuthService) + + def test_empty_uri_allowed(self): + app = _app(["https://example.com/callback"]) + assert self.svc.validate_redirect_uri("", app) is True + + def test_exact_match(self): + app = _app(["https://example.com/callback"]) + assert ( + self.svc.validate_redirect_uri("https://example.com/callback", app) is True + ) + + def test_exact_no_match(self): + app = _app(["https://example.com/callback"]) + assert self.svc.validate_redirect_uri("https://evil.com/callback", app) is False + + def test_wildcard_query_match(self): + app = _app(["https://raycast.com/redirect?*"]) + assert ( + self.svc.validate_redirect_uri( + "https://raycast.com/redirect?packageName=spoo&state=abc", app + ) + is True + ) + + def test_wildcard_prefix_only(self): + app = _app(["https://raycast.com/redirect?*"]) + assert ( + self.svc.validate_redirect_uri("https://raycast.com/redirect?", app) is True + ) + + def test_wildcard_rejects_different_path(self): + app = _app(["https://raycast.com/redirect?*"]) + assert ( + self.svc.validate_redirect_uri("https://raycast.com/redirected", app) + is False + ) + + def test_wildcard_rejects_different_host(self): + app = _app(["https://raycast.com/redirect?*"]) + assert ( + self.svc.validate_redirect_uri("https://evil.com/redirect?foo=bar", app) + is False + ) + + def test_no_uris_rejects(self): + app = _app([]) + assert self.svc.validate_redirect_uri("https://example.com", app) is False + + def test_multiple_uris_mixed(self): + app = _app(["https://a.com/cb", "https://b.com/redirect?*"]) + assert self.svc.validate_redirect_uri("https://a.com/cb", app) is True + assert self.svc.validate_redirect_uri("https://b.com/redirect?x=1", app) is True + assert self.svc.validate_redirect_uri("https://c.com/cb", app) is False + + def test_bare_star_matches_everything(self): + """A bare '*' entry matches any URI — intentional if configured.""" + app = _app(["*"]) + assert ( + self.svc.validate_redirect_uri("https://anything.com/whatever", app) is True + )