const std = @import("std"); pub const RepoAction = enum { create, update, delete }; pub const SpaceAction = enum { read_self, read, create, update, delete, manage_create, manage_update, manage_delete }; pub const AccountAction = enum { read, manage }; pub const AccountAttr = enum { email, repo, status, wildcard }; pub const IdentityAttr = enum { handle, wildcard }; pub fn oauthNeedsGranularCheck(maybe_scope: ?[]const u8) bool { const scope_text = maybe_scope orelse return false; return !std.mem.eql(u8, scope_text, "com.atproto.access") and !std.mem.eql(u8, scope_text, "atproto"); } pub fn repoAllows(maybe_scope: ?[]const u8, action: RepoAction, collection: []const u8) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "repo")) continue; if (repoScopeMatches(scope, action, collection)) return true; } return false; } pub fn blobAllows(maybe_scope: ?[]const u8, mime_type: []const u8) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "blob")) continue; if (blobScopeMatches(scope, mime_type)) return true; } return false; } pub fn rpcAllows(maybe_scope: ?[]const u8, aud: []const u8, lxm: []const u8) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "rpc:")) continue; if (rpcScopeMatches(scope, aud, lxm)) return true; } return false; } pub fn spaceAllows( maybe_scope: ?[]const u8, action: SpaceAction, space_type: []const u8, did: []const u8, skey: []const u8, collection: ?[]const u8, ) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scope_iter = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scope_iter.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "space:")) continue; if (spaceScopeMatches(scope, action, space_type, did, skey, collection)) return true; } return false; } pub fn accountAllows(maybe_scope: ?[]const u8, attr: AccountAttr, action: AccountAction) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "account")) continue; if (accountScopeMatches(scope, attr, action)) return true; } return false; } pub fn identityAllows(maybe_scope: ?[]const u8, attr: IdentityAttr) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (scope.len == 0) continue; if (std.mem.eql(u8, scope, "transition:generic")) return true; if (!std.mem.startsWith(u8, scope, "identity")) continue; if (identityScopeMatches(scope, attr)) return true; } return false; } pub fn hasFullAccess(maybe_scope: ?[]const u8) bool { if (!oauthNeedsGranularCheck(maybe_scope)) return true; var scopes = std.mem.splitScalar(u8, maybe_scope.?, ' '); while (scopes.next()) |scope| { if (std.mem.eql(u8, scope, "atproto") or std.mem.eql(u8, scope, "transition:generic")) return true; } return false; } fn accountScopeMatches(scope: []const u8, attr: AccountAttr, action: AccountAction) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (!accountAttrMatches(base, attr)) return false; if (query_start == null) return true; var saw_action = false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (!std.mem.startsWith(u8, param, "action=")) continue; saw_action = true; if (std.mem.eql(u8, param["action=".len..], accountActionName(action))) return true; } return !saw_action; } fn accountAttrMatches(base: []const u8, attr: AccountAttr) bool { if (std.mem.eql(u8, base, "account") or std.mem.eql(u8, base, "account:") or std.mem.eql(u8, base, "account:*")) return true; if (!std.mem.startsWith(u8, base, "account:")) return false; const allowed = base["account:".len..]; if (attr == .wildcard) return std.mem.eql(u8, allowed, "*"); return std.mem.eql(u8, allowed, accountAttrName(attr)); } fn accountActionName(action: AccountAction) []const u8 { return switch (action) { .read => "read", .manage => "manage", }; } fn accountAttrName(attr: AccountAttr) []const u8 { return switch (attr) { .email => "email", .repo => "repo", .status => "status", .wildcard => "*", }; } fn identityScopeMatches(scope: []const u8, attr: IdentityAttr) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (std.mem.eql(u8, base, "identity") or std.mem.eql(u8, base, "identity:") or std.mem.eql(u8, base, "identity:*")) return true; if (!std.mem.startsWith(u8, base, "identity:")) return false; const allowed = base["identity:".len..]; return switch (attr) { .handle => std.mem.eql(u8, allowed, "handle"), .wildcard => false, }; } fn repoScopeMatches(scope: []const u8, action: RepoAction, collection: []const u8) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (!repoScopeCollectionMatches(base, collection)) return false; if (query_start == null) return true; var saw_action = false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (!std.mem.startsWith(u8, param, "action=")) continue; saw_action = true; if (std.mem.eql(u8, param["action=".len..], repoActionName(action))) return true; } return !saw_action; } fn repoScopeCollectionMatches(base: []const u8, collection: []const u8) bool { if (std.mem.eql(u8, base, "repo") or std.mem.eql(u8, base, "repo:") or std.mem.eql(u8, base, "repo:*")) return true; if (!std.mem.startsWith(u8, base, "repo:")) return false; const allowed = base["repo:".len..]; if (std.mem.eql(u8, allowed, collection)) return true; if (std.mem.endsWith(u8, allowed, ".*")) { const prefix = allowed[0 .. allowed.len - 2]; return std.mem.startsWith(u8, collection, prefix) and collection.len > prefix.len and collection[prefix.len] == '.'; } return false; } fn repoActionName(action: RepoAction) []const u8 { return switch (action) { .create => "create", .update => "update", .delete => "delete", }; } fn blobScopeMatches(scope: []const u8, mime_type: []const u8) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (blobMimeMatches(blobScopePattern(base), mime_type)) return true; if (query_start == null) return false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (!std.mem.startsWith(u8, param, "accept=")) continue; if (blobMimeMatches(param["accept=".len..], mime_type)) return true; } return false; } fn blobScopePattern(base: []const u8) []const u8 { if (std.mem.eql(u8, base, "blob") or std.mem.eql(u8, base, "blob:")) return "*/*"; if (!std.mem.startsWith(u8, base, "blob:")) return ""; return base["blob:".len..]; } fn blobMimeMatches(pattern: []const u8, mime_type: []const u8) bool { if (pattern.len == 0) return false; if (std.mem.eql(u8, pattern, "*/*")) return true; if (std.mem.eql(u8, pattern, mime_type)) return true; if (std.mem.endsWith(u8, pattern, "/*")) { const prefix = pattern[0 .. pattern.len - 2]; return std.mem.startsWith(u8, mime_type, prefix) and mime_type.len > prefix.len and mime_type[prefix.len] == '/'; } return false; } fn rpcScopeMatches(scope: []const u8, aud: []const u8, lxm: []const u8) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (!rpcMethodMatches(base, lxm)) return false; if (query_start == null) return false; var saw_aud = false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (!std.mem.startsWith(u8, param, "aud=")) continue; saw_aud = true; const allowed = param["aud=".len..]; if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, aud)) return true; } return !saw_aud; } fn rpcMethodMatches(base: []const u8, lxm: []const u8) bool { if (!std.mem.startsWith(u8, base, "rpc:")) return false; const allowed = base["rpc:".len..]; if (std.mem.eql(u8, allowed, "*") or std.mem.eql(u8, allowed, lxm)) return true; if (std.mem.endsWith(u8, allowed, ".*")) { const prefix = allowed[0 .. allowed.len - 2]; return std.mem.startsWith(u8, lxm, prefix) and lxm.len > prefix.len and lxm[prefix.len] == '.'; } return false; } fn spaceScopeMatches( scope: []const u8, action: SpaceAction, space_type: []const u8, authority: []const u8, skey: []const u8, collection: ?[]const u8, ) bool { const query_start = std.mem.indexOfScalar(u8, scope, '?'); const base = if (query_start) |idx| scope[0..idx] else scope; if (!spaceTypeMatches(base, space_type)) return false; if (query_start == null) return action == .read; var saw_action = false; var action_matches = false; var saw_manage = false; var manage_matches = false; var saw_collection = false; var collection_matches = false; var params = std.mem.splitScalar(u8, scope[query_start.? + 1 ..], '&'); while (params.next()) |param| { if (std.mem.startsWith(u8, param, "action=")) { saw_action = true; const allowed = param["action=".len..]; action_matches = action_matches or switch (action) { .read_self => std.mem.eql(u8, allowed, "read_self") or std.mem.eql(u8, allowed, "read"), .read, .create, .update, .delete => std.mem.eql(u8, allowed, spaceActionName(action)), else => false, }; } else if (std.mem.startsWith(u8, param, "manage=")) { saw_manage = true; manage_matches = manage_matches or std.mem.eql(u8, param["manage=".len..], spaceManageName(action)); } else if (std.mem.startsWith(u8, param, "authority=")) { const allowed = param["authority=".len..]; if (std.mem.eql(u8, allowed, "self")) return false; if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, authority)) return false; } else if (std.mem.startsWith(u8, param, "skey=")) { const allowed = param["skey=".len..]; if (!std.mem.eql(u8, allowed, "*") and !std.mem.eql(u8, allowed, skey)) return false; } else if (std.mem.startsWith(u8, param, "collection=")) { if (action == .read or switch (action) { .manage_create, .manage_update, .manage_delete => true, else => false, }) continue; saw_collection = true; const requested = collection orelse return false; const allowed = param["collection=".len..]; collection_matches = collection_matches or collectionPatternMatches(allowed, requested); } } return switch (action) { .manage_create, .manage_update, .manage_delete => saw_manage and manage_matches, .read => if (saw_action) action_matches else true, .read_self => (if (saw_action) action_matches else true) and saw_collection and collection_matches, .create, .update, .delete => (if (saw_action) action_matches else true) and saw_collection and collection_matches, }; } fn spaceTypeMatches(base: []const u8, space_type: []const u8) bool { const prefix = "space:"; if (!std.mem.startsWith(u8, base, prefix)) return false; const allowed = base[prefix.len..]; return collectionPatternMatches(allowed, space_type); } fn collectionPatternMatches(pattern: []const u8, value: []const u8) bool { if (std.mem.eql(u8, pattern, "*") or std.mem.eql(u8, pattern, value)) return true; if (std.mem.endsWith(u8, pattern, ".*")) { const prefix = pattern[0 .. pattern.len - 2]; return std.mem.startsWith(u8, value, prefix) and value.len > prefix.len and value[prefix.len] == '.'; } return false; } fn spaceActionName(action: SpaceAction) []const u8 { return switch (action) { .read_self => "read_self", .read => "read", .create => "create", .update => "update", .delete => "delete", .manage_create, .manage_update, .manage_delete => "", }; } fn spaceManageName(action: SpaceAction) []const u8 { return switch (action) { .manage_create => "create", .manage_update => "update", .manage_delete => "delete", else => "", }; } test "repo scopes constrain action and collection" { try std.testing.expect(repoAllows("repo:*?action=create", .create, "app.bsky.feed.post")); try std.testing.expect(!repoAllows("repo:*?action=create", .delete, "app.bsky.feed.post")); try std.testing.expect(repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.feed.like")); try std.testing.expect(!repoAllows("repo:app.bsky.feed.*", .update, "app.bsky.graph.follow")); } test "blob scopes constrain mime types" { try std.testing.expect(blobAllows("blob:image/*", "image/jpeg")); try std.testing.expect(!blobAllows("blob:image/*", "video/mp4")); try std.testing.expect(blobAllows("blob?accept=image/*&accept=video/*", "video/mp4")); } test "rpc scopes constrain method and audience" { try std.testing.expect(rpcAllows("rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getTimeline")); try std.testing.expect(rpcAllows("rpc:*?aud=did:web:api.bsky.app#bsky_appview", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); try std.testing.expect(rpcAllows("rpc:app.bsky.feed.*?aud=*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); try std.testing.expect(!rpcAllows("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app#bsky_appview", "did:web:video.bsky.app#video", "app.bsky.feed.getFeed")); try std.testing.expect(!rpcAllows("repo:*", "did:web:api.bsky.app#bsky_appview", "app.bsky.feed.getFeed")); } test "space scopes constrain type identity key action and collection" { try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&skey=self&collection=fm.plyr.track&action=create", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&skey=self&action=read", .create, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); try std.testing.expect(spaceAllows("space:fm.plyr.*?authority=*&skey=*&action=read", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&collection=fm.plyr.track&action=read_self", .read_self, "fm.plyr.privateMedia", "did:plc:alice", "self", "fm.plyr.track")); try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&collection=fm.plyr.track&action=read", .read, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); try std.testing.expect(spaceAllows("space:fm.plyr.privateMedia?authority=did:plc:alice&manage=update", .manage_update, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); try std.testing.expect(!spaceAllows("space:fm.plyr.privateMedia?authority=self&manage=update", .manage_update, "fm.plyr.privateMedia", "did:plc:alice", "self", null)); try std.testing.expect(!spaceAllows("repo:*", .read, "fm.plyr.privateMedia", "did:plc:bob", "records", null)); } test "account scopes constrain attribute and action" { try std.testing.expect(accountAllows("account:email?action=manage", .email, .manage)); try std.testing.expect(!accountAllows("account:email?action=read", .email, .manage)); try std.testing.expect(!accountAllows("account:repo?action=manage", .email, .manage)); try std.testing.expect(accountAllows("account:*", .repo, .manage)); } test "identity scopes constrain attribute" { try std.testing.expect(identityAllows("identity:handle", .handle)); try std.testing.expect(!identityAllows("identity:handle", .wildcard)); try std.testing.expect(identityAllows("identity:*", .wildcard)); }