diff --git a/atproto/auth/oauthscope.go b/atproto/auth/oauthscope.go new file mode 100644 --- /dev/null +++ b/atproto/auth/oauthscope.go @@ -0,0 +1,35 @@ +package auth + +import ( + "fmt" + "strings" +) + +// High-level helper for parsing a space-delimited OAuth scope string in to a set of permissions. +// +// If the 'atproto' scope is not included, this will return an error. Otherwise invalid permission scope strings are simply ignored. +func ParseOAuthScope(scope string) ([]Permission, error) { + + foundAtproto := false + perms := []Permission{} + + parts := strings.Split(scope, " ") + for _, p := range parts { + if p == "" { + continue + } + if p == "atproto" { + foundAtproto = true + continue + } + perm, err := ParsePermissionString(p) + if err != nil { + continue + } + perms = append(perms, *perm) + } + if !foundAtproto { + return nil, fmt.Errorf("required 'atproto' scope not found") + } + return perms, nil +} diff --git a/atproto/auth/oauthscope_test.go b/atproto/auth/oauthscope_test.go new file mode 100644 --- /dev/null +++ b/atproto/auth/oauthscope_test.go @@ -0,0 +1,22 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseOAuthScope(t *testing.T) { + assert := assert.New(t) + + perms, err := ParseOAuthScope("") + assert.Error(err) + + perms, err = ParseOAuthScope("atproto repo:*") + assert.NoError(err) + assert.Equal(1, len(perms)) + + perms, err = ParseOAuthScope("atproto rpc:asdf") + assert.NoError(err) + assert.Equal(0, len(perms)) +} diff --git a/atproto/auth/permission.go b/atproto/auth/permission.go new file mode 100644 --- /dev/null +++ b/atproto/auth/permission.go @@ -0,0 +1,288 @@ +package auth + +import ( + "errors" + "fmt" + "net/url" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +var ( + ErrInvalidPermissionSyntax = errors.New("invalid permission syntax") + ErrInvalidPermissionParams = errors.New("invalid permission parameters") + ErrUnknownResource = errors.New("unknown permission resource") +) + +// Parsed components of an AT permission, as currently specified. +// +// This type is somewhat redundant with the "SchemaPermission" type in the indigo lexicon package, but it can represent all possible permissions, not just those found in permission sets. +type Permission struct { + Type string `json:"type,omitempty"` + Resource string `json:"resource"` + + // common params (eg, identity, account) + Accept []string `json:"accept,omitempty"` + Action []string `json:"action,omitempty"` + Attribute string `json:"attr,omitempty"` + Audience string `json:"aud,omitempty"` + InheritAud bool `json:"inheritAud,omitempty"` + Collection []string `json:"collection,omitempty"` + Endpoint []string `json:"lxm,omitempty"` + NSID string `json:"nsid,omitempty"` +} + +// Renders a permission as a permission scope string. +// +// If the permission contains information which only makes sense in the context of a permission-set (eg, the inheritAud flag), it will be silently dropped. +func (p *Permission) ScopeString() string { + + positional := "" + params := make(url.Values) + + switch p.Resource { + case "account": + if p.Attribute != "" { + positional = p.Attribute + } + if len(p.Action) != 0 { + params["action"] = p.Action + } + case "blob": + if len(p.Accept) == 1 { + positional = p.Accept[0] + } else if len(p.Accept) > 1 { + params["accept"] = p.Accept + } + case "identity": + if p.Attribute != "" { + positional = p.Attribute + } + case "include": + if p.NSID != "" { + positional = p.NSID + } + if p.Audience != "" { + params.Set("aud", p.Audience) + } + case "repo": + if len(p.Collection) == 1 { + positional = p.Collection[0] + } else if len(p.Collection) > 1 { + params["collection"] = p.Collection + } + if len(p.Action) != 0 { + params["action"] = p.Action + } + case "rpc": + if len(p.Endpoint) == 1 { + positional = p.Endpoint[0] + } else if len(p.Endpoint) > 1 { + params["lxm"] = p.Endpoint + } + if p.Audience != "" { + params.Set("aud", p.Audience) + } + default: + return "" + } + + scope := p.Resource + if positional != "" { + scope = scope + ":" + positional + } + if len(params) > 0 { + scope = scope + "?" + params.Encode() + } + return scope +} + +// Parses a permission scope string (as would be found as a component of an OAuth scope string) into a [Permission]. +// +// This function is strict: it is case sensitive, verifies field syntax, and will throw an error on unknown parameters/fields. Note that calling code is usually supposed to simply skip any permission which cause such errors, not reject entire requests. +func ParsePermissionString(scope string) (*Permission, error) { + g, err := ParseGenericScope(scope) + if err != nil { + return nil, err + } + + p := Permission{ + Type: "permission", + Resource: g.Resource, + } + + switch g.Resource { + case "account": + for k, _ := range g.Params { + if !(k == "attr" || k == "action") { + return nil, fmt.Errorf("%w: unsupported 'account' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("attr") { + if g.Positional != "" || len(g.Params["attr"]) != 1 { + return nil, ErrInvalidPermissionParams + } + p.Attribute = g.Params.Get("attr") + } + if g.Positional != "" { + p.Attribute = g.Positional + } + if p.Attribute == "" { + return nil, ErrInvalidPermissionParams + } + if p.Attribute != "" && p.Attribute != "email" && p.Attribute != "repo" { + return nil, ErrInvalidPermissionParams + } + // TODO: maybe this should not be limited to a single "action" string? + if len(g.Params["action"]) > 1 { + return nil, ErrInvalidPermissionParams + } + p.Action = g.Params["action"] + for _, act := range p.Action { + if act != "read" && act != "manage" { + return nil, ErrInvalidPermissionParams + } + } + case "blob": + for k, _ := range g.Params { + if !(k == "accept") { + return nil, fmt.Errorf("%w: unsupported 'blob' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("accept") { + if g.Positional != "" { + return nil, ErrInvalidPermissionParams + } + p.Accept = g.Params["accept"] + } + if g.Positional != "" { + p.Accept = []string{g.Positional} + } + if len(p.Accept) == 0 { + return nil, ErrInvalidPermissionParams + } + for _, acc := range p.Accept { + if !validBlobAccept(acc) { + return nil, ErrInvalidPermissionParams + } + } + case "identity": + for k, _ := range g.Params { + if !(k == "attr") { + return nil, fmt.Errorf("%w: unsupported 'identity' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("attr") { + if g.Positional != "" || len(g.Params["attr"]) != 1 { + return nil, ErrInvalidPermissionParams + } + p.Attribute = g.Params.Get("attr") + } + if g.Positional != "" { + p.Attribute = g.Positional + } + if p.Attribute != "*" && p.Attribute != "handle" { + return nil, ErrInvalidPermissionParams + } + case "include": + for k, _ := range g.Params { + if !(k == "nsid" || k == "aud") { + return nil, fmt.Errorf("%w: unsupported 'include' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("nsid") { + if g.Positional != "" || len(g.Params["nsid"]) != 1 { + return nil, ErrInvalidPermissionParams + } + p.NSID = g.Params.Get("nsid") + } + if g.Positional != "" { + p.NSID = g.Positional + } + _, err := syntax.ParseNSID(p.NSID) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) + } + if g.Params.Has("aud") && (len(g.Params["aud"]) != 1 || g.Params.Get("aud") == "") { + return nil, ErrInvalidPermissionParams + } + p.Audience = g.Params.Get("aud") + if p.Audience != "" && p.Audience != "*" && !validServiceRef(p.Audience) { + return nil, ErrInvalidPermissionParams + } + // possibly other params in the future... + case "repo": + for k, _ := range g.Params { + if !(k == "collection" || k == "action") { + return nil, fmt.Errorf("%w: unsupported 'repo' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("collection") { + if g.Positional != "" { + return nil, ErrInvalidPermissionParams + } + p.Collection = g.Params["collection"] + } + if g.Positional != "" { + p.Collection = []string{g.Positional} + } + if len(p.Collection) == 0 { + return nil, ErrInvalidPermissionParams + } + for _, coll := range p.Collection { + if coll == "*" { + continue + } + _, err := syntax.ParseNSID(coll) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) + } + } + p.Action = g.Params["action"] + for _, act := range p.Action { + if act != "create" && act != "update" && act != "delete" { + return nil, ErrInvalidPermissionParams + } + } + case "rpc": + for k, _ := range g.Params { + if !(k == "lxm" || k == "aud") { + return nil, fmt.Errorf("%w: unsupported 'rpc' param: %s", ErrInvalidPermissionParams, k) + } + } + if g.Params.Has("lxm") { + if g.Positional != "" { + return nil, ErrInvalidPermissionParams + } + p.Endpoint = g.Params["lxm"] + } + if g.Positional != "" { + p.Endpoint = []string{g.Positional} + } + if len(p.Endpoint) == 0 { + return nil, ErrInvalidPermissionParams + } + if len(g.Params["aud"]) != 1 { + return nil, ErrInvalidPermissionParams + } + p.Audience = g.Params.Get("aud") + for _, nsid := range p.Endpoint { + if nsid == "*" { + if p.Audience == "*" { + return nil, ErrInvalidPermissionParams + } + continue + } + _, err := syntax.ParseNSID(nsid) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionParams, err) + } + } + if p.Audience != "*" && !validServiceRef(p.Audience) { + return nil, ErrInvalidPermissionParams + } + default: + return nil, fmt.Errorf("%w: %s", ErrUnknownResource, g.Resource) + } + return &p, nil +} diff --git a/atproto/auth/permission_test.go b/atproto/auth/permission_test.go new file mode 100644 --- /dev/null +++ b/atproto/auth/permission_test.go @@ -0,0 +1,146 @@ +package auth + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRoundTrip(t *testing.T) { + assert := assert.New(t) + + // NOTE: this escapes colons and slashes, which aren't strictly necessary + testScopes := []string{ + "repo:com.example.record?action=delete", + "repo?action=delete&collection=com.example.record&collection=com.example.other", + "rpc:com.example.query?aud=did%3Aweb%3Aapi.example.com%23frag", + "rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure", + "blob:image/*", + "blob?accept=image%2Fpng&accept=image%2Fjpeg", + "account:email?action=manage", + "identity:handle", + "include:app.example.authBasics", + } + + for _, scope := range testScopes { + p, err := ParsePermissionString(scope) + assert.NoError(err) + if err != nil { + fmt.Println("BAD: " + scope) + continue + } + assert.Equal(scope, p.ScopeString()) + } +} + +type GenericExample struct { + Scope string `json:"scope"` + Generic GenericPermission `json:"generic"` +} + +func TestGenericGenericScopesValid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/generic_scopes.json") + if err != nil { + assert.NoError(err) + t.Fail() + } + defer file.Close() + + var fixtures []GenericExample + if err := json.NewDecoder(file).Decode(&fixtures); err != nil { + assert.NoError(err) + t.Fail() + } + + for _, fix := range fixtures { + gp, err := ParseGenericScope(fix.Scope) + if err != nil { + fmt.Println("BAD: " + fix.Scope) + assert.NoError(err) + continue + } + assert.Equal(fix.Generic, *gp) + } +} + +func TestGenericScopesInvalid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/generic_scopes_invalid.txt") + if err != nil { + assert.NoError(err) + t.Fail() + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + _, err := ParseGenericScope(line) + if err != nil { + fmt.Println("BAD: " + line) + } + assert.Error(err) + } + assert.NoError(scanner.Err()) +} + +func TestInteropPermissionValid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/permission_scopes_valid.txt") + if err != nil { + assert.NoError(err) + t.Fail() + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + _, err := ParseGenericScope(line) + if err != nil { + fmt.Println("BAD: " + line) + } + assert.NoError(err) + p, err := ParsePermissionString(line) + if err != nil { + fmt.Println("BAD: " + line) + } + assert.NoError(err) + if p != nil { + assert.False(p.ScopeString() == "") + } + } + assert.NoError(scanner.Err()) +} + +func TestInteropPermissionInvalid(t *testing.T) { + assert := assert.New(t) + file, err := os.Open("testdata/permission_scopes_invalid.txt") + if err != nil { + assert.NoError(err) + t.Fail() + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 || line[0] == '#' { + continue + } + _, err := ParsePermissionString(line) + if err == nil { + fmt.Println("BAD: " + line) + } + assert.Error(err) + } + assert.NoError(scanner.Err()) +} diff --git a/atproto/auth/permission_util.go b/atproto/auth/permission_util.go new file mode 100644 --- /dev/null +++ b/atproto/auth/permission_util.go @@ -0,0 +1,95 @@ +package auth + +import ( + "fmt" + "net/url" + "strings" + "unicode" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// Parsed components of a generic AT scope string. This is for internal or low-level use; most code should use [ParsePermissionString] instead. +type GenericPermission struct { + Resource string `json:"resource"` + Positional string `json:"positional"` + Params url.Values `json:"params"` +} + +func ParseGenericScope(scope string) (*GenericPermission, error) { + + if !isASCII(scope) { + return nil, ErrInvalidPermissionSyntax + } + + front, query, _ := strings.Cut(scope, "?") + resource, positional, _ := strings.Cut(front, ":") + + // XXX: more charset restrictions + + params, err := url.ParseQuery(query) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidPermissionSyntax, err) + } + + p := GenericPermission{ + Resource: resource, + Positional: positional, + Params: params, + } + return &p, nil +} + +func (p *GenericPermission) ScopeString() string { + scope := p.Resource + if p.Positional != "" { + scope = scope + ":" + p.Positional + } + if len(p.Params) > 0 { + scope = scope + "?" + p.Params.Encode() + } + return scope +} + +// TODO: replace with helper in syntax pkg +func validBlobAccept(accept string) bool { + if accept == "*/*" { + return true + } + parts := strings.SplitN(accept, "/", 3) + if len(parts) != 2 { + return false + } + if parts[0] == "*" { + return false + } + if parts[1] == "**" { + return false + } + return true +} + +// TODO: replace with helper in syntax pkg +func validServiceRef(accept string) bool { + parts := strings.SplitN(accept, "#", 3) + if len(parts) != 2 { + return false + } + _, err := syntax.ParseDID(parts[0]) + if err != nil { + return false + } + if len(parts[1]) == 0 { + return false + } + return true +} + +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/atproto/auth/testdata/generic_scopes.json b/atproto/auth/testdata/generic_scopes.json new file mode 100644 --- /dev/null +++ b/atproto/auth/testdata/generic_scopes.json @@ -0,0 +1,168 @@ +[ + { + "scope": "resource", + "generic": { + "resource": "resource", + "positional": "", + "params": {} + } + }, + { + "scope": "resource:positional?key=val", + "generic": { + "resource": "resource", + "positional": "positional", + "params": { + "key": ["val"] + } + } + }, + { + "scope": "resource:positional?thing&key=val", + "generic": { + "resource": "resource", + "positional": "positional", + "params": { + "thing": [""], + "key": ["val"] + } + } + }, + { + "scope": "service:did:web:com.example#type?key=val", + "generic": { + "resource": "service", + "positional": "did:web:com.example#type", + "params": { + "key": ["val"] + } + } + }, + { + "scope": "resource:", + "generic": { + "resource": "resource", + "positional": "", + "params": {} + } + }, + { + "scope": "resource:?", + "generic": { + "resource": "resource", + "positional": "", + "params": {} + } + }, + { + "scope": "resource:&", + "generic": { + "resource": "resource", + "positional": "&", + "params": {} + } + }, + { + "scope": "resource?", + "generic": { + "resource": "resource", + "positional": "", + "params": {} + } + }, + { + "scope": "res:pos?p=true", + "generic": { + "resource": "res", + "positional": "pos", + "params": { + "p": ["true"] + } + } + }, + { + "scope": "my-res", + "generic": { + "resource": "my-res", + "positional": "", + "params": {} + } + }, + { + "scope": "my-res:my-pos", + "generic": { + "resource": "my-res", + "positional": "my-pos", + "params": {} + } + }, + { + "scope": "my-res:", + "generic": { + "resource": "my-res", + "positional": "", + "params": {} + } + }, + { + "scope": "my-res:foo?x=value&y=value-y", + "generic": { + "resource": "my-res", + "positional": "foo", + "params": { + "x": ["value"], + "y": ["value-y"] + } + } + }, + { + "scope": "my-res?x=value&y=value-y", + "generic": { + "resource": "my-res", + "positional": "", + "params": { + "x": ["value"], + "y": ["value-y"] + } + } + }, + { + "scope": "my-res?x=foo&x=bar&x=baz", + "generic": { + "resource": "my-res", + "positional": "", + "params": { + "x": ["foo", "bar", "baz"] + } + } + }, + + { + "scope": "rpc:foo.bar?aud=did:foo:bar?lxm=bar.baz", + "generic": { + "resource": "rpc", + "positional": "foo.bar", + "params": { + "aud": ["did:foo:bar?lxm=bar.baz"] + } + } + }, + { + "scope": "my-res?x=my%20value", + "generic": { + "resource": "my-res", + "positional": "", + "params": { + "x": ["my value"] + } + } + }, + { + "scope": "my-res:my:pos", + "generic": { + "resource": "my-res", + "positional": "my:pos", + "params": {} + } + } +] diff --git a/atproto/auth/testdata/generic_scopes_invalid.txt b/atproto/auth/testdata/generic_scopes_invalid.txt new file mode 100644 --- /dev/null +++ b/atproto/auth/testdata/generic_scopes_invalid.txt @@ -0,0 +1,2 @@ +resource:positional?key=québec +emoji:☺️ diff --git a/atproto/auth/testdata/permission_scopes_invalid.txt b/atproto/auth/testdata/permission_scopes_invalid.txt new file mode 100644 --- /dev/null +++ b/atproto/auth/testdata/permission_scopes_invalid.txt @@ -0,0 +1,105 @@ + +invalid +scope +invalid:email + +account:invalid +account:email?action=invalid +account +account: +account:status?action=manage +account:status +Account:email +account:Email + +blob +blob:invalid +blob?accept=invalid-mime +blob?accept=invalid +blob:*/** +blob:*/png +blob?Accept=image/png +Blob?accept=image/png + +identity:invalid +identity:*?attr=* +identity:*?action=* +identity:invalid +identity:handle?action=invalid +identity?attribute=invalid&action=invalid +Identity:handle +identity:Handle +identity:*?action=manage +identity:*?action=submit + +include +include# +Include:app.example.authBasics + +# invalid NSID +include: +include:# +include:& +include:com..example +include:com +include:com.example +include:9com.example.foo +include:com.example.-bar +include:invalid^nsid +include:nsid + +# invalid AUD +include:com.example.baz?aud= +include:com.example.baz?aud=did:web:example.com +include:com.example.baz?aud=invalid^did +include:com.example.baz?aud=invalid^did + +repo:foo bar +repo:.foo +repo:bar. +repo:com.example.foo?action=invalid +repo:123 +repo +repo: +repo:*?action=* +repo:invalid +repo:com.example.foo?action=invalid +repo?collection=invalid&action=invalid +Repo:com.example.foo +repo:*?Action=create +repo:*?action=Create + +rpc +rpc:123 +rpc:com.example.method1?aud=did:web:example.com&lxm=com.example.method2 +rpc:com.example.query?aud=api.example.com +rpc?aud=*&lxm=* +rpc:invalid +rpc?lxm=invalid +rpc:* +rpc:invalid?aud=did:web:example.com +rpc:invalid?aud=did:web:example.com%23service_id +rpc:foo.bar +rpc:foo.bar.baz?aud=did:web +rpc:foo.bar.baz?aud=did:web%23service_id +rpc:foo.bar.baz?aud=did:plc:111 +rpc:foo.bar.baz?aud=did:foo:bar +rpc:foo.bar.baz?aud=did:web:example.com%23service_id&lxm=foo.bar.baz +rpc:foo.bar.baz?aud=invalid +rpc:invalid?aud=did:web:example.com +rpc:invalid?aud=did:web:example.com%23service_id +rpc:com.example.service?aud=invalid +notrpc:com.example.service?aud=did:web:example.com%23service_id +rpc?lxm=invalid&aud=invalid +rpc?Lxm=com.example.method1&aud=* +Rpc?lxm=com.example.method1&aud=* +rpc:com.example.service?aud=did:web:example.com%23service_id&invalid=param + +# missing LXM +rpc?aud=did:web:example.com%23service_id +rpc:?aud=did:web:example.com%23service_id +rpc?aud=did:web:example.com + +# missing AUD +rpc?lxm=com.example.method1 +rpc:com.example.method1 diff --git a/atproto/auth/testdata/permission_scopes_valid.txt b/atproto/auth/testdata/permission_scopes_valid.txt new file mode 100644 --- /dev/null +++ b/atproto/auth/testdata/permission_scopes_valid.txt @@ -0,0 +1,60 @@ + +account:email?action=read +account:email?action=manage +account:repo?action=manage +account:email +account:repo +account?attr=email + +blob:image/png +blob:*/* +blob:image/* +blob?accept=image/png + +identity:handle +identity:* +identity?attr=handle + +include:app.example.authBasics +include:com.example.bar +include:com.example.baz?aud=did:web:example.com%23my_service +include:com.example.baz?aud=did:web:example.com#my_service +include?nsid=com.example.baz +include?aud=did:web:example.com%23my_service&nsid=com.example.baz +include:com.example.calendar.auth + +repo:com.example.foo +repo:com.example.foo?action=create&action=update +repo:*?action=create +repo:* +repo?collection=com.example.foo&action=create&action=update&action=delete +repo?action=create&collection=com.example.foo&collection=com.example.bar + +rpc:com.example.service?aud=did:web:example.com%23service_id +rpc?lxm=com.example.method1&aud=* +rpc:com.example.method1?aud=* +rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:web:example.com%23service_id +rpc?aud=*&lxm=com.example.method1&lxm=com.example.method2 +rpc:com.example.query?aud=did:web:api.example.com%23api_example +rpc?aud=did%3Aweb%3Aapi.example.com%23frag&lxm=com.example.query&lxm=com.example.procedure + + +# examples from specification text +repo:app.example.profile +repo:app.example.profile?action=create&action=update&action=delete +repo?collection=app.example.profile&collection=app.example.post +repo:* +repo:*?action=delete +rpc:app.example.moderation.createReport?aud=* +rpc?lxm=*&aud=did:web:api.example.com%23svc_appview +blob:*/* +blob?accept=video/*&accept=text/html +account:email +account:repo?action=manage +identity:handle +identity:* +identity:*? +rpc?lxm=*&aud=did:web:api.example.com%23svc_appview +blob?accept=video/*&accept=text/html +repo:app.example.profile?action=create&action=update&action=delete +include:app.example.authFull?aud=did:web:api.example.com%23svc_chat