A community based topic aggregation platform built on atproto

feat(oauth): support custom URL schemes per atproto spec

Per atproto OAuth spec, native mobile apps can use custom URL schemes
where the scheme matches the client_id hostname in reverse-domain order.

For coves.social, the allowed scheme is "social.coves".

Supported redirect URIs:
- social.coves:/callback (custom scheme per atproto spec)
- social.coves://callback
- social.coves:/oauth/callback
- social.coves://oauth/callback
- https://coves.social/app/oauth/callback (Universal Link)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+38 -28
internal
+22 -19
internal/atproto/oauth/handlers_security.go
···
)
// allowedMobileRedirectURIs contains the EXACT allowed redirect URIs for mobile apps.
-
// SECURITY: Only Universal Links (HTTPS) are allowed - cryptographically bound to app.
//
-
// Universal Links provide strong security guarantees:
// - iOS: Verified via /.well-known/apple-app-site-association
// - Android: Verified via /.well-known/assetlinks.json
-
// - System verifies domain ownership before routing to app
-
// - Prevents malicious apps from intercepting OAuth callbacks
-
//
-
// Custom URL schemes (coves-app://, coves://) are NOT allowed because:
-
// - Any app can register the same scheme and intercept tokens
-
// - No cryptographic binding to app identity
-
// - Token theft is trivial for malicious apps
-
//
-
// See: https://atproto.com/specs/oauth#mobile-clients
var allowedMobileRedirectURIs = map[string]bool{
-
// Universal Links only - cryptographically bound to app
"https://coves.social/app/oauth/callback": true,
}
// isAllowedMobileRedirectURI validates that the redirect URI is in the exact allowlist.
-
// SECURITY: Exact URI matching prevents token theft by rogue apps that register the same scheme.
//
-
// Custom URL schemes are NOT cryptographically bound to apps:
-
// - Any app on the device can register "coves-app://" or "coves://"
-
// - A malicious app can intercept deep links intended for Coves
-
// - Without exact URI matching, the attacker receives the sealed token
//
-
// This function performs EXACT matching (not scheme-only) as a security measure.
-
// For production, migrate to Universal Links (iOS) or App Links (Android).
func isAllowedMobileRedirectURI(redirectURI string) bool {
// Normalize and check exact match
return allowedMobileRedirectURIs[redirectURI]
···
)
// allowedMobileRedirectURIs contains the EXACT allowed redirect URIs for mobile apps.
+
//
+
// Per atproto OAuth spec (https://atproto.com/specs/oauth#mobile-clients):
+
// - Custom URL schemes are allowed for native mobile apps
+
// - The scheme must match the client_id hostname in REVERSE-DOMAIN order
+
// - For client_id https://coves.social/..., the scheme is "social.coves"
+
//
+
// We support two redirect URI types:
+
// 1. Custom scheme: social.coves:/callback (per atproto spec, simpler for mobile)
+
// 2. Universal Links: https://coves.social/app/oauth/callback (cryptographically bound)
//
+
// Universal Links provide stronger security guarantees but require:
// - iOS: Verified via /.well-known/apple-app-site-association
// - Android: Verified via /.well-known/assetlinks.json
var allowedMobileRedirectURIs = map[string]bool{
+
// Custom scheme per atproto spec (reverse-domain of coves.social)
+
"social.coves:/callback": true,
+
"social.coves://callback": true, // Some platforms add double slash
+
"social.coves:/oauth/callback": true, // Alternative path
+
"social.coves://oauth/callback": true,
+
// Universal Links - cryptographically bound to app (preferred for security)
"https://coves.social/app/oauth/callback": true,
}
// isAllowedMobileRedirectURI validates that the redirect URI is in the exact allowlist.
+
// SECURITY: Exact URI matching prevents token theft by rogue apps.
//
+
// Per atproto OAuth spec, custom schemes must match the client_id hostname
+
// in reverse-domain order (social.coves for coves.social), which provides
+
// some protection as malicious apps would need to know the specific scheme.
//
+
// Universal Links (https://) provide stronger security as they're cryptographically
+
// bound to the app via .well-known verification files.
func isAllowedMobileRedirectURI(redirectURI string) bool {
// Normalize and check exact match
return allowedMobileRedirectURIs[redirectURI]
+16 -9
internal/atproto/oauth/handlers_test.go
···
}
// TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching
-
// Only Universal Links (HTTPS) are allowed - custom schemes are blocked for security
func TestIsMobileRedirectURI(t *testing.T) {
tests := []struct {
uri string
expected bool
}{
-
{"https://coves.social/app/oauth/callback", true}, // Universal Link - allowed
-
{"coves-app://oauth/callback", false}, // Custom scheme - blocked (insecure)
-
{"coves://oauth/callback", false}, // Custom scheme - blocked (insecure)
-
{"coves-app://callback", false}, // Custom scheme - blocked
-
{"coves://oauth", false}, // Custom scheme - blocked
-
{"myapp://oauth", false}, // Not in allowlist
-
{"https://example.com", false}, // Wrong domain
-
{"http://localhost", false}, // HTTP not allowed
{"", false},
{"not-a-uri", false},
}
···
}
// TestIsMobileRedirectURI tests mobile redirect URI validation with EXACT URI matching
+
// Per atproto spec, custom schemes must match client_id hostname in reverse-domain order
func TestIsMobileRedirectURI(t *testing.T) {
tests := []struct {
uri string
expected bool
}{
+
// Custom scheme per atproto spec (reverse domain of coves.social)
+
{"social.coves:/callback", true},
+
{"social.coves://callback", true},
+
{"social.coves:/oauth/callback", true},
+
{"social.coves://oauth/callback", true},
+
// Universal Link - allowed (strongest security)
+
{"https://coves.social/app/oauth/callback", true},
+
// Wrong custom schemes - not reverse-domain of coves.social
+
{"coves-app://oauth/callback", false},
+
{"coves://oauth/callback", false},
+
{"coves.social://callback", false}, // Not reversed
+
{"myapp://oauth", false},
+
// Wrong domain/scheme
+
{"https://example.com", false},
+
{"http://localhost", false},
{"", false},
{"not-a-uri", false},
}