├── .github └── workflows │ └── go.yaml ├── .travis.yml ├── LICENSE ├── README.md ├── _example ├── index.html ├── login.html └── server.go ├── discover.go ├── discover_test.go ├── discovery_cache.go ├── discovery_cache_test.go ├── fake_getter_test.go ├── getter.go ├── go.mod ├── go.sum ├── html_discovery.go ├── html_discovery_test.go ├── integration ├── discovery_test.go └── doc.go ├── nonce_store.go ├── nonce_store_test.go ├── normalizer.go ├── normalizer_test.go ├── openid.go ├── redirect.go ├── redirect_test.go ├── verify.go ├── verify_test.go ├── xrds.go ├── xrds_test.go ├── yadis_discovery.go └── yadis_discovery_test.go /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ $default-branch ] 6 | pull_request: 7 | branches: [ $default-branch ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.19 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -v . -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: go 4 | 5 | go: 6 | - 1.3.x 7 | - 1.4.x 8 | - 1.5.x 9 | - 1.6.x 10 | - 1.7.x 11 | - 1.8.x 12 | - 1.9.x 13 | - 1.10.x 14 | - 1.11.x 15 | - 1.12.x 16 | 17 | env: 18 | - GO111MODULE=on 19 | 20 | # Get deps, build, test, and ensure the code is gofmt'ed. 21 | script: 22 | - go test -v ./... 23 | - diff <(gofmt -d .) <("") 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Yohann Coppel 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openid.go 2 | 3 | This is a consumer (Relying party) implementation of OpenId 2.0, 4 | written in Go. 5 | 6 | go get -u github.com/yohcop/openid-go 7 | 8 | [![Build Status](https://travis-ci.org/yohcop/openid-go.svg?branch=master)](https://travis-ci.org/yohcop/openid-go) 9 | 10 | ## Github 11 | 12 | Be awesome! Feel free to clone and use according to the licence. 13 | If you make a useful change that can benefit others, send a 14 | pull request! This ensures that one version has all the good stuff 15 | and doesn't fall behind. 16 | 17 | ## Code example 18 | 19 | See `_example/` for a simple webserver using the openID 20 | implementation. Also, read the comment about the NonceStore towards 21 | the top of that file. The example must be run for the openid-go 22 | directory, like so: 23 | 24 | go run _example/server.go 25 | 26 | ## App Engine 27 | 28 | In order to use this on Google App Engine, you need to create an instance with a custom `*http.Client` provided by [urlfetch](https://cloud.google.com/appengine/docs/go/urlfetch/). 29 | 30 | ```go 31 | oid := openid.NewOpenID(urlfetch.Client(appengine.NewContext(r))) 32 | oid.RedirectURL(...) 33 | oid.Verify(...) 34 | ``` 35 | 36 | ## License 37 | 38 | Distributed under the [Apache v2.0 license](http://www.apache.org/licenses/LICENSE-2.0.html). 39 | 40 | ## Libraries 41 | 42 | Here is a set of libraries I found on GitHub that could make using this library easier depending on your backends. I haven't tested them, this list is for reference only, and in no particular order: 43 | 44 | - [Gacnt/myopenid](https://github.com/Gacnt/myopenid) "A Yohcop-Openid Nonce/Discovery storage replacement", using MySQL. 45 | - [Gacnt/sqlxid](https://github.com/Gacnt/sqlxid) "An SQLX Adapter for Nonce / Discovery Cache store" 46 | - [Gacnt/gormid](https://github.com/Gacnt/gormid) "Use GORM (Go Object Relational Mapping) to store OpenID DiscoveryCache / Nonce in a database" 47 | - [hectorj/mysqlOpenID](https://github.com/hectorj/mysqlOpenID) "MySQL OpenID is a package to replace the in memory storage of discoveryCache and nonceStore." 48 | -------------------------------------------------------------------------------- /_example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | go OpenID sample app 4 | 5 | 6 |

go OpenID sample app

7 | 8 | {{if .user}} 9 |

Welcome {{.user}}.

10 |

Devs: set a cookie, or this kind of things to 11 | identify the user across different pages.

12 | {{else}} 13 | Login 14 | {{end}} 15 | 16 | 17 | -------------------------------------------------------------------------------- /_example/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | go OpenID sample app 4 | 11 | 12 | 13 |

Login

14 | 15 |
16 | 17 |
18 | 19 |

Sign in with:

20 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /_example/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/yohcop/openid-go" 5 | "html/template" 6 | "log" 7 | "net/http" 8 | ) 9 | 10 | const dataDir = "_example/" 11 | 12 | // For the demo, we use in-memory infinite storage nonce and discovery 13 | // cache. In your app, do not use this as it will eat up memory and never 14 | // free it. Use your own implementation, on a better database system. 15 | // If you have multiple servers for example, you may need to share at least 16 | // the nonceStore between them. 17 | var nonceStore = openid.NewSimpleNonceStore() 18 | var discoveryCache = openid.NewSimpleDiscoveryCache() 19 | 20 | func indexHandler(w http.ResponseWriter, r *http.Request) { 21 | p := make(map[string]string) 22 | if t, err := template.ParseFiles(dataDir + "index.html"); err == nil { 23 | t.Execute(w, p) 24 | } else { 25 | log.Print(err) 26 | } 27 | } 28 | 29 | func loginHandler(w http.ResponseWriter, r *http.Request) { 30 | p := make(map[string]string) 31 | if t, err := template.ParseFiles(dataDir + "login.html"); err == nil { 32 | t.Execute(w, p) 33 | } else { 34 | log.Print(err) 35 | } 36 | } 37 | 38 | func discoverHandler(w http.ResponseWriter, r *http.Request) { 39 | if url, err := openid.RedirectURL(r.FormValue("id"), 40 | "http://localhost:8080/openidcallback", 41 | "http://localhost:8080/"); err == nil { 42 | http.Redirect(w, r, url, 303) 43 | } else { 44 | log.Print(err) 45 | } 46 | } 47 | 48 | func callbackHandler(w http.ResponseWriter, r *http.Request) { 49 | fullUrl := "http://localhost:8080" + r.URL.String() 50 | log.Print(fullUrl) 51 | id, err := openid.Verify( 52 | fullUrl, 53 | discoveryCache, nonceStore) 54 | if err == nil { 55 | p := make(map[string]string) 56 | p["user"] = id 57 | if t, err := template.ParseFiles(dataDir + "index.html"); err == nil { 58 | t.Execute(w, p) 59 | } else { 60 | log.Println("WTF") 61 | log.Print(err) 62 | } 63 | } else { 64 | log.Println("WTF2") 65 | log.Print(err) 66 | } 67 | } 68 | 69 | func main() { 70 | http.HandleFunc("/", indexHandler) 71 | http.HandleFunc("/login", loginHandler) 72 | http.HandleFunc("/discover", discoverHandler) 73 | http.HandleFunc("/openidcallback", callbackHandler) 74 | http.ListenAndServe(":8080", nil) 75 | } 76 | -------------------------------------------------------------------------------- /discover.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | // 7.3.1. Discovered Information 4 | // Upon successful completion of discovery, the Relying Party will 5 | // have one or more sets of the following information (see the 6 | // Terminology section for definitions). If more than one set of the 7 | // following information has been discovered, the precedence rules 8 | // defined in [XRI_Resolution_2.0] are to be applied. 9 | // - OP Endpoint URL 10 | // - Protocol Version 11 | // If the end user did not enter an OP Identifier, the following 12 | // information will also be present: 13 | // - Claimed Identifier 14 | // - OP-Local Identifier 15 | // If the end user entered an OP Identifier, there is no Claimed 16 | // Identifier. For the purposes of making OpenID Authentication 17 | // requests, the value 18 | // "http://specs.openid.net/auth/2.0/identifier_select" MUST be 19 | // used as both the Claimed Identifier and the OP-Local Identifier 20 | // when an OP Identifier is entered. 21 | func Discover(id string) (opEndpoint, opLocalID, claimedID string, err error) { 22 | return defaultInstance.Discover(id) 23 | } 24 | 25 | func (oid *OpenID) Discover(id string) (opEndpoint, opLocalID, claimedID string, err error) { 26 | // From OpenID specs, 7.2: Normalization 27 | if id, err = Normalize(id); err != nil { 28 | return 29 | } 30 | 31 | // From OpenID specs, 7.3: Discovery. 32 | 33 | // If the identifier is an XRI, [XRI_Resolution_2.0] will yield an 34 | // XRDS document that contains the necessary information. It 35 | // should also be noted that Relying Parties can take advantage of 36 | // XRI Proxy Resolvers, such as the one provided by XDI.org at 37 | // http://www.xri.net. This will remove the need for the RPs to 38 | // perform XRI Resolution locally. 39 | 40 | // XRI not supported. 41 | 42 | // If it is a URL, the Yadis protocol [Yadis] SHALL be first 43 | // attempted. If it succeeds, the result is again an XRDS 44 | // document. 45 | if opEndpoint, opLocalID, err = yadisDiscovery(id, oid.urlGetter); err != nil { 46 | // If the Yadis protocol fails and no valid XRDS document is 47 | // retrieved, or no Service Elements are found in the XRDS 48 | // document, the URL is retrieved and HTML-Based discovery SHALL be 49 | // attempted. 50 | opEndpoint, opLocalID, claimedID, err = htmlDiscovery(id, oid.urlGetter) 51 | } 52 | 53 | if err != nil { 54 | return "", "", "", err 55 | } 56 | return 57 | } 58 | -------------------------------------------------------------------------------- /discover_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDiscoverWithYadis(t *testing.T) { 8 | // They all redirect to the same XRDS document 9 | expectOpIDErr(t, "example.com/xrds", 10 | "foo", "bar", "", false) 11 | expectOpIDErr(t, "http://example.com/xrds", 12 | "foo", "bar", "", false) 13 | expectOpIDErr(t, "http://example.com/xrds-loc", 14 | "foo", "bar", "", false) 15 | expectOpIDErr(t, "http://example.com/xrds-meta", 16 | "foo", "bar", "", false) 17 | } 18 | 19 | func TestDiscoverWithHtml(t *testing.T) { 20 | // Yadis discovery will fail, and fall back to html. 21 | expectOpIDErr(t, "http://example.com/html", 22 | "example.com/openid", "bar-name", "http://example.com/html", 23 | false) 24 | // The first url redirects to a different URL. The redirected-to 25 | // url should be used as claimedID. 26 | expectOpIDErr(t, "http://example.com/html-redirect", 27 | "example.com/openid", "bar-name", "http://example.com/html", 28 | false) 29 | 30 | expectOpIDErr(t, "http://example.com/html-multi-rel", 31 | "http://www.livejournal.com/openid/server.bml", 32 | "http://exampleuser.livejournal.com/", 33 | "http://example.com/html-multi-rel", 34 | false) 35 | } 36 | 37 | func TestDiscoverBadUrl(t *testing.T) { 38 | expectOpIDErr(t, "http://example.com/404", "", "", "", true) 39 | } 40 | 41 | func expectOpIDErr(t *testing.T, uri, exOpEndpoint, exOpLocalID, exClaimedID string, exErr bool) { 42 | opEndpoint, opLocalID, claimedID, err := testInstance.Discover(uri) 43 | if (err != nil) != exErr { 44 | t.Errorf("Unexpected error: '%s'", err) 45 | } else { 46 | if opEndpoint != exOpEndpoint { 47 | t.Errorf("Extracted Endpoint does not match: Exepect %s, Got %s", 48 | exOpEndpoint, opEndpoint) 49 | } 50 | if opLocalID != exOpLocalID { 51 | t.Errorf("Extracted LocalId does not match: Exepect %s, Got %s", 52 | exOpLocalID, opLocalID) 53 | } 54 | if claimedID != exClaimedID { 55 | t.Errorf("Extracted ClaimedID does not match: Exepect %s, Got %s", 56 | exClaimedID, claimedID) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /discovery_cache.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | type DiscoveredInfo interface { 8 | OpEndpoint() string 9 | OpLocalID() string 10 | ClaimedID() string 11 | // ProtocolVersion: it's always openId 2. 12 | } 13 | 14 | type DiscoveryCache interface { 15 | Put(id string, info DiscoveredInfo) 16 | // Return a discovered info, or nil. 17 | Get(id string) DiscoveredInfo 18 | } 19 | 20 | type SimpleDiscoveredInfo struct { 21 | opEndpoint string 22 | opLocalID string 23 | claimedID string 24 | } 25 | 26 | func (s *SimpleDiscoveredInfo) OpEndpoint() string { 27 | return s.opEndpoint 28 | } 29 | 30 | func (s *SimpleDiscoveredInfo) OpLocalID() string { 31 | return s.opLocalID 32 | } 33 | 34 | func (s *SimpleDiscoveredInfo) ClaimedID() string { 35 | return s.claimedID 36 | } 37 | 38 | type SimpleDiscoveryCache struct { 39 | cache map[string]DiscoveredInfo 40 | mutex *sync.Mutex 41 | } 42 | 43 | func NewSimpleDiscoveryCache() *SimpleDiscoveryCache { 44 | return &SimpleDiscoveryCache{cache: map[string]DiscoveredInfo{}, mutex: &sync.Mutex{}} 45 | } 46 | 47 | func (s *SimpleDiscoveryCache) Put(id string, info DiscoveredInfo) { 48 | s.mutex.Lock() 49 | defer s.mutex.Unlock() 50 | 51 | s.cache[id] = info 52 | } 53 | 54 | func (s *SimpleDiscoveryCache) Get(id string) DiscoveredInfo { 55 | s.mutex.Lock() 56 | defer s.mutex.Unlock() 57 | 58 | if info, has := s.cache[id]; has { 59 | return info 60 | } 61 | return nil 62 | } 63 | 64 | func compareDiscoveredInfo(a DiscoveredInfo, opEndpoint, opLocalID, claimedID string) bool { 65 | return a != nil && 66 | a.OpEndpoint() == opEndpoint && 67 | a.OpLocalID() == opLocalID && 68 | a.ClaimedID() == claimedID 69 | } 70 | -------------------------------------------------------------------------------- /discovery_cache_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDiscoveryCache(t *testing.T) { 8 | dc := NewSimpleDiscoveryCache() 9 | 10 | // Put some initial values 11 | dc.Put("foo", &SimpleDiscoveredInfo{opEndpoint: "a", opLocalID: "b", claimedID: "c"}) 12 | 13 | // Make sure we can retrieve them 14 | if di := dc.Get("foo"); di == nil { 15 | t.Errorf("Expected a result, got nil") 16 | } else if di.OpEndpoint() != "a" || di.OpLocalID() != "b" || di.ClaimedID() != "c" { 17 | t.Errorf("Expected a b c, got %v %v %v", di.OpEndpoint(), di.OpLocalID(), di.ClaimedID()) 18 | } 19 | 20 | // Attempt to get a non-existent value 21 | if di := dc.Get("bar"); di != nil { 22 | t.Errorf("Expected nil, got %v", di) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /fake_getter_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "errors" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type fakeGetter struct { 12 | urls map[string]string 13 | redirects map[string]string 14 | } 15 | 16 | var testGetter = &fakeGetter{ 17 | make(map[string]string), make(map[string]string)} 18 | 19 | var testInstance = &OpenID{urlGetter: testGetter} 20 | 21 | func (f *fakeGetter) Get(uri string, headers map[string]string) (resp *http.Response, err error) { 22 | key := uri 23 | for k, v := range headers { 24 | key += "#" + k + "#" + v 25 | } 26 | 27 | if doc, ok := f.urls[key]; ok { 28 | request, err := http.NewRequest("GET", uri, nil) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return http.ReadResponse(bufio.NewReader( 34 | bytes.NewBuffer([]byte(doc))), request) 35 | } 36 | if uri, ok := f.redirects[key]; ok { 37 | return f.Get(uri, headers) 38 | } 39 | 40 | return nil, errors.New("404 not found") 41 | } 42 | 43 | func (f *fakeGetter) Post(uri string, form url.Values) (resp *http.Response, err error) { 44 | return f.Get("POST@"+uri, nil) 45 | } 46 | 47 | func init() { 48 | // Prepare (http#header#header-val --> http response) pairs. 49 | 50 | // === For Yadis discovery ================================== 51 | // Directly reffers a valid XRDS document 52 | testGetter.urls["http://example.com/xrds#Accept#application/xrds+xml"] = `HTTP/1.0 200 OK 53 | Content-Type: application/xrds+xml; charset=UTF-8 54 | 55 | 56 | 58 | 59 | 60 | http://specs.openid.net/auth/2.0/signon 61 | foo 62 | bar 63 | 64 | 65 | ` 66 | 67 | // Uses a X-XRDS-Location header to redirect to the valid XRDS document. 68 | testGetter.urls["http://example.com/xrds-loc#Accept#application/xrds+xml"] = `HTTP/1.0 200 OK 69 | X-XRDS-Location: http://example.com/xrds 70 | 71 | nothing interesting here` 72 | 73 | // Html document, with meta tag X-XRDS-Location. Points to the 74 | // previous valid XRDS document. 75 | testGetter.urls["http://example.com/xrds-meta#Accept#application/xrds+xml"] = `HTTP/1.0 200 OK 76 | Content-Type: text/html 77 | 78 | 79 | 80 | ` 81 | 82 | // === For HTML discovery =================================== 83 | testGetter.urls["http://example.com/html"] = `HTTP/1.0 200 OK 84 | 85 | 86 | 87 | 88 | ` 89 | 90 | testGetter.redirects["http://example.com/html-redirect"] = "http://example.com/html" 91 | 92 | testGetter.urls["http://example.com/html-multi-rel"] = `HTTP/1.0 200 OK 93 | Content-Type: text/html 94 | 95 | 96 | 97 | 99 | ` 101 | 102 | } 103 | -------------------------------------------------------------------------------- /getter.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | ) 7 | 8 | // Interface that simplifies testing. 9 | type httpGetter interface { 10 | Get(uri string, headers map[string]string) (resp *http.Response, err error) 11 | Post(uri string, form url.Values) (resp *http.Response, err error) 12 | } 13 | 14 | type defaultGetter struct { 15 | client *http.Client 16 | } 17 | 18 | func (dg *defaultGetter) Get(uri string, headers map[string]string) (resp *http.Response, err error) { 19 | request, err := http.NewRequest("GET", uri, nil) 20 | if err != nil { 21 | return 22 | } 23 | for h, v := range headers { 24 | request.Header.Add(h, v) 25 | } 26 | return dg.client.Do(request) 27 | } 28 | 29 | func (dg *defaultGetter) Post(uri string, form url.Values) (resp *http.Response, err error) { 30 | return dg.client.PostForm(uri, form) 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yohcop/openid-go 2 | 3 | go 1.3 4 | 5 | require golang.org/x/net v0.7.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 4 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 5 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 6 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 7 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 8 | golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= 9 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 10 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 11 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 12 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 13 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 15 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 17 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 18 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 19 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 20 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 21 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 22 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 23 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 24 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 25 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 26 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 27 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 28 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 29 | -------------------------------------------------------------------------------- /html_discovery.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "strings" 7 | 8 | "golang.org/x/net/html" 9 | ) 10 | 11 | func htmlDiscovery(id string, getter httpGetter) (opEndpoint, opLocalID, claimedID string, err error) { 12 | resp, err := getter.Get(id, nil) 13 | if err != nil { 14 | return "", "", "", err 15 | } 16 | opEndpoint, opLocalID, err = findProviderFromHeadLink(resp.Body) 17 | return opEndpoint, opLocalID, resp.Request.URL.String(), err 18 | } 19 | 20 | func findProviderFromHeadLink(input io.Reader) (opEndpoint, opLocalID string, err error) { 21 | tokenizer := html.NewTokenizer(input) 22 | inHead := false 23 | for { 24 | tt := tokenizer.Next() 25 | switch tt { 26 | case html.ErrorToken: 27 | // Even if the document is malformed after we found a 28 | // valid tag, ignore and let's be happy with our 29 | // openid2.provider and potentially openid2.local_id as well. 30 | if len(opEndpoint) > 0 { 31 | return 32 | } 33 | return "", "", tokenizer.Err() 34 | case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: 35 | tk := tokenizer.Token() 36 | if tk.Data == "head" { 37 | if tt == html.StartTagToken { 38 | inHead = true 39 | } else { 40 | if len(opEndpoint) > 0 { 41 | return 42 | } 43 | return "", "", errors.New( 44 | "LINK with rel=openid2.provider not found") 45 | } 46 | } else if inHead && tk.Data == "link" { 47 | provider := false 48 | localID := false 49 | href := "" 50 | for _, attr := range tk.Attr { 51 | if attr.Key == "rel" { 52 | // There could be multiple values in the rel= attribute, 53 | // space-separated (it seems to be for OpenID1 vs 2.) 54 | // See example in Appendix A.4. HTML Identifier Markup. 55 | vals := strings.Split(attr.Val, " ") 56 | for _, val := range vals { 57 | if val == "openid2.provider" { 58 | provider = true 59 | break 60 | } else if val == "openid2.local_id" { 61 | localID = true 62 | break 63 | } 64 | } 65 | } else if attr.Key == "href" { 66 | href = attr.Val 67 | } 68 | } 69 | if provider && !localID && len(href) > 0 { 70 | opEndpoint = href 71 | } else if !provider && localID && len(href) > 0 { 72 | opLocalID = href 73 | } 74 | } 75 | } 76 | } 77 | // At this point we should probably have returned either from 78 | // a closing or a tokenizer error (no found). 79 | // But just in case. 80 | if len(opEndpoint) > 0 { 81 | return 82 | } 83 | return "", "", errors.New("LINK rel=openid2.provider not found") 84 | } 85 | -------------------------------------------------------------------------------- /html_discovery_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestFindEndpointFromLink(t *testing.T) { 9 | searchLink(t, ` 10 | 11 | 12 | 13 | `, "example.com/openid", "", false) 14 | searchLink(t, ` 15 | 16 | 17 | 18 | 19 | 20 | 21 | `, "foo.com", "bar-name", false) 22 | // Self-closing link 23 | searchLink(t, ` 24 | 25 | 26 | 27 | 28 | 29 | 30 | `, "selfclose.com", "selfclose-name", false) 31 | } 32 | 33 | func TestNoEndpointFromLink(t *testing.T) { 34 | searchLink(t, ` 35 | 36 | 37 | 38 | `, "", "", true) 39 | // Outside of head. 40 | searchLink(t, ` 41 | 42 | 43 | 44 | `, "", "", true) 45 | } 46 | 47 | func searchLink(t *testing.T, doc, opEndpoint, claimedID string, err bool) { 48 | r := bytes.NewReader([]byte(doc)) 49 | op, id, e := findProviderFromHeadLink(r) 50 | if (e != nil) != err { 51 | t.Errorf("Unexpected error: '%s'", e) 52 | } else if e == nil { 53 | if op != opEndpoint { 54 | t.Errorf("Found bad endpoint: Expected %s, Got %s", 55 | op, opEndpoint) 56 | } 57 | if id != claimedID { 58 | t.Errorf("Found bad id: Expected %s, Got %s", 59 | id, claimedID) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /integration/discovery_test.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | // These tests fetch real data from google.com and other OpenID 4 | // providers. If they change the files returned, or endpoints, or 5 | // whatever, they will fail. It's ok though, they are full tests. 6 | 7 | import ( 8 | . "github.com/yohcop/openid-go" 9 | "testing" 10 | ) 11 | 12 | func TestYahoo(t *testing.T) { 13 | expectDiscovery(t, "https://me.yahoo.com", 14 | "https://open.login.yahooapis.com/openid/op/auth", 15 | "", 16 | "") 17 | } 18 | 19 | func TestYohcop(t *testing.T) { 20 | expectDiscovery(t, "http://yohcop.net", 21 | "https://www.google.com/accounts/o8/ud?source=profiles", 22 | "http://www.google.com/profiles/yohcop", 23 | "http://yohcop.net/") 24 | } 25 | 26 | func TestSteam(t *testing.T) { 27 | expectDiscovery(t, "http://steamcommunity.com/openid", 28 | "https://steamcommunity.com/openid/login", 29 | "", 30 | "") 31 | } 32 | 33 | func expectDiscovery(t *testing.T, uri, expectOp, expectLocalId, expectClaimedId string) { 34 | endpoint, localId, claimedId, err := Discover(uri) 35 | if err != nil { 36 | t.Errorf("Discovery failed") 37 | } 38 | if endpoint != expectOp { 39 | t.Errorf("Unexpected endpoint: %s", endpoint) 40 | } 41 | if localId != expectLocalId { 42 | t.Errorf("Unexpected localId: %s", localId) 43 | } 44 | if claimedId != expectClaimedId { 45 | t.Errorf("Unexpected claimedId: %s", claimedId) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /integration/doc.go: -------------------------------------------------------------------------------- 1 | package integration 2 | 3 | // This package only contains a test. 4 | -------------------------------------------------------------------------------- /nonce_store.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | var maxNonceAge = flag.Duration("openid-max-nonce-age", 12 | 60*time.Second, 13 | "Maximum accepted age for openid nonces. The bigger, the more"+ 14 | "memory is needed to store used nonces.") 15 | 16 | type NonceStore interface { 17 | // Returns nil if accepted, an error otherwise. 18 | Accept(endpoint, nonce string) error 19 | } 20 | 21 | type Nonce struct { 22 | T time.Time 23 | S string 24 | } 25 | 26 | type SimpleNonceStore struct { 27 | store map[string][]*Nonce 28 | mutex *sync.Mutex 29 | } 30 | 31 | func NewSimpleNonceStore() *SimpleNonceStore { 32 | return &SimpleNonceStore{store: map[string][]*Nonce{}, mutex: &sync.Mutex{}} 33 | } 34 | 35 | func (d *SimpleNonceStore) Accept(endpoint, nonce string) error { 36 | // Value: A string 255 characters or less in length, that MUST be 37 | // unique to this particular successful authentication response. 38 | if len(nonce) < 20 || len(nonce) > 256 { 39 | return errors.New("Invalid nonce") 40 | } 41 | 42 | // The nonce MUST start with the current time on the server, and MAY 43 | // contain additional ASCII characters in the range 33-126 inclusive 44 | // (printable non-whitespace characters), as necessary to make each 45 | // response unique. The date and time MUST be formatted as specified in 46 | // section 5.6 of [RFC3339], with the following restrictions: 47 | 48 | // All times must be in the UTC timezone, indicated with a "Z". No 49 | // fractional seconds are allowed For example: 50 | // 2005-05-15T17:11:51ZUNIQUE 51 | ts, err := time.Parse(time.RFC3339, nonce[0:20]) 52 | if err != nil { 53 | return err 54 | } 55 | now := time.Now() 56 | diff := now.Sub(ts) 57 | if diff > *maxNonceAge { 58 | return fmt.Errorf("Nonce too old: %.2fs", diff.Seconds()) 59 | } 60 | 61 | s := nonce[20:] 62 | 63 | // Meh.. now we have to use a mutex, to protect that map from 64 | // concurrent access. Could put a go routine in charge of it 65 | // though. 66 | d.mutex.Lock() 67 | defer d.mutex.Unlock() 68 | 69 | if nonces, hasOp := d.store[endpoint]; hasOp { 70 | // Delete old nonces while we are at it. 71 | newNonces := []*Nonce{{ts, s}} 72 | for _, n := range nonces { 73 | if n.T == ts && n.S == s { 74 | // If return early, just ignore the filtered list 75 | // we have been building so far... 76 | return errors.New("Nonce already used") 77 | } 78 | if now.Sub(n.T) < *maxNonceAge { 79 | newNonces = append(newNonces, n) 80 | } 81 | } 82 | d.store[endpoint] = newNonces 83 | } else { 84 | d.store[endpoint] = []*Nonce{{ts, s}} 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /nonce_store_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestDefaultNonceStore(t *testing.T) { 9 | *maxNonceAge = 60 * time.Second 10 | now := time.Now().UTC() 11 | // 30 seconds ago 12 | now30s := now.Add(-30 * time.Second) 13 | // 2 minutes ago 14 | now2m := now.Add(-2 * time.Minute) 15 | 16 | now30sStr := now30s.Format(time.RFC3339) 17 | now2mStr := now2m.Format(time.RFC3339) 18 | 19 | ns := NewSimpleNonceStore() 20 | reject(t, ns, "1", "foo") // invalid nonce 21 | reject(t, ns, "1", "fooBarBazLongerThan20Chars") // invalid nonce 22 | 23 | accept(t, ns, "1", now30sStr+"asd") 24 | reject(t, ns, "1", now30sStr+"asd") // same nonce 25 | accept(t, ns, "1", now30sStr+"xxx") // different nonce 26 | reject(t, ns, "1", now30sStr+"xxx") // different nonce again to verify storage of multiple nonces per endpoint 27 | accept(t, ns, "2", now30sStr+"asd") // different endpoint 28 | 29 | reject(t, ns, "1", now2mStr+"old") // too old 30 | reject(t, ns, "3", now2mStr+"old") // too old 31 | } 32 | 33 | func accept(t *testing.T, ns NonceStore, op, nonce string) { 34 | e := ns.Accept(op, nonce) 35 | if e != nil { 36 | t.Errorf("Should accept %s nonce %s", op, nonce) 37 | } 38 | } 39 | 40 | func reject(t *testing.T, ns NonceStore, op, nonce string) { 41 | e := ns.Accept(op, nonce) 42 | if e == nil { 43 | t.Errorf("Should reject %s nonce %s", op, nonce) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /normalizer.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | func Normalize(id string) (string, error) { 10 | id = strings.TrimSpace(id) 11 | if len(id) == 0 { 12 | return "", errors.New("No id provided") 13 | } 14 | 15 | // 7.2 from openID 2.0 spec. 16 | 17 | //If the user's input starts with the "xri://" prefix, it MUST be 18 | //stripped off, so that XRIs are used in the canonical form. 19 | if strings.HasPrefix(id, "xri://") { 20 | id = id[6:] 21 | return id, errors.New("XRI identifiers not supported") 22 | } 23 | 24 | // If the first character of the resulting string is an XRI 25 | // Global Context Symbol ("=", "@", "+", "$", "!") or "(", as 26 | // defined in Section 2.2.1 of [XRI_Syntax_2.0], then the input 27 | // SHOULD be treated as an XRI. 28 | if b := id[0]; b == '=' || b == '@' || b == '+' || b == '$' || b == '!' || b == '(' { 29 | return id, errors.New("XRI identifiers not supported") 30 | } 31 | 32 | // Otherwise, the input SHOULD be treated as an http URL; if it 33 | // does not include a "http" or "https" scheme, the Identifier 34 | // MUST be prefixed with the string "http://". If the URL 35 | // contains a fragment part, it MUST be stripped off together 36 | // with the fragment delimiter character "#". See Section 11.5.2 for 37 | // more information. 38 | if !strings.HasPrefix(id, "http://") && !strings.HasPrefix(id, 39 | "https://") { 40 | id = "http://" + id 41 | } 42 | if fragmentIndex := strings.Index(id, "#"); fragmentIndex != -1 { 43 | id = id[0:fragmentIndex] 44 | } 45 | if u, err := url.ParseRequestURI(id); err != nil { 46 | return "", err 47 | } else { 48 | if u.Host == "" { 49 | return "", errors.New("Invalid address provided as id") 50 | } 51 | if u.Path == "" { 52 | u.Path = "/" 53 | } 54 | id = u.String() 55 | } 56 | 57 | // URL Identifiers MUST then be further normalized by both 58 | // following redirects when retrieving their content and finally 59 | // applying the rules in Section 6 of [RFC3986] to the final 60 | // destination URL. This final URL MUST be noted by the Relying 61 | // Party as the Claimed Identifier and be used when requesting 62 | // authentication. 63 | return id, nil 64 | } 65 | -------------------------------------------------------------------------------- /normalizer_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestNormalize(t *testing.T) { 8 | // OpenID 2.0 spec Appendix A.1. Normalization 9 | doNormalize(t, "example.com", "http://example.com/", true) 10 | doNormalize(t, "http://example.com", "http://example.com/", true) 11 | doNormalize(t, "https://example.com/", "https://example.com/", true) 12 | doNormalize(t, "http://example.com/user", "http://example.com/user", true) 13 | doNormalize(t, "http://example.com/user/", "http://example.com/user/", true) 14 | doNormalize(t, "http://example.com/", "http://example.com/", true) 15 | doNormalize(t, "=example", "=example", false) // XRI not supported 16 | doNormalize(t, "(=example)", "(=example)", false) // XRI not supported 17 | doNormalize(t, "xri://=example", "=example", false) // XRI not supported 18 | 19 | // Empty 20 | doNormalize(t, "", "", false) 21 | doNormalize(t, " ", "", false) 22 | doNormalize(t, " ", "", false) 23 | doNormalize(t, "xri://", "", false) 24 | doNormalize(t, "http://", "", false) 25 | doNormalize(t, "https://", "", false) 26 | 27 | // Padded with spacing 28 | doNormalize(t, " example.com ", "http://example.com/", true) 29 | doNormalize(t, " http://example.com ", "http://example.com/", true) 30 | 31 | // XRI not supported 32 | doNormalize(t, "xri://asdf", "asdf", false) 33 | doNormalize(t, "=asdf", "=asdf", false) 34 | doNormalize(t, "@asdf", "@asdf", false) 35 | 36 | // HTTP 37 | doNormalize(t, "foo.com", "http://foo.com/", true) 38 | doNormalize(t, "http://foo.com", "http://foo.com/", true) 39 | doNormalize(t, "https://foo.com", "https://foo.com/", true) 40 | 41 | // Fragment need to be removed 42 | doNormalize(t, "http://foo.com#bar", "http://foo.com/", true) 43 | doNormalize(t, "http://foo.com/page#bar", "http://foo.com/page", true) 44 | } 45 | 46 | func doNormalize(t *testing.T, idIn, idOut string, succeed bool) { 47 | if id, err := Normalize(idIn); err != nil && succeed { 48 | t.Errorf("unexpected normalize error: gave %v, expected %v, got %v - %v", idIn, idOut, id, err) 49 | } else if err == nil && !succeed { 50 | t.Errorf("unexpected normalize success: gave %v, expected %v, got %v", idIn, idOut, id) 51 | } else if id != idOut { 52 | t.Errorf("unexpected normalize result: gave %v, expected %v, got %v", idIn, idOut, id) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /openid.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type OpenID struct { 8 | urlGetter httpGetter 9 | } 10 | 11 | func NewOpenID(client *http.Client) *OpenID { 12 | return &OpenID{urlGetter: &defaultGetter{client: client}} 13 | } 14 | 15 | var defaultInstance = NewOpenID(http.DefaultClient) 16 | -------------------------------------------------------------------------------- /redirect.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | func RedirectURL(id, callbackURL, realm string) (string, error) { 9 | return defaultInstance.RedirectURL(id, callbackURL, realm) 10 | } 11 | 12 | func (oid *OpenID) RedirectURL(id, callbackURL, realm string) (string, error) { 13 | opEndpoint, opLocalID, claimedID, err := oid.Discover(id) 14 | if err != nil { 15 | return "", err 16 | } 17 | return BuildRedirectURL(opEndpoint, opLocalID, claimedID, callbackURL, realm) 18 | } 19 | 20 | func BuildRedirectURL(opEndpoint, opLocalID, claimedID, returnTo, realm string) (string, error) { 21 | values := make(url.Values) 22 | values.Add("openid.ns", "http://specs.openid.net/auth/2.0") 23 | values.Add("openid.mode", "checkid_setup") 24 | values.Add("openid.return_to", returnTo) 25 | 26 | // 9.1. Request Parameters 27 | // "openid.claimed_id" and "openid.identity" SHALL be either both present or both absent. 28 | if len(claimedID) > 0 { 29 | values.Add("openid.claimed_id", claimedID) 30 | if len(opLocalID) > 0 { 31 | values.Add("openid.identity", opLocalID) 32 | } else { 33 | // If a different OP-Local Identifier is not specified, 34 | // the claimed identifier MUST be used as the value for openid.identity. 35 | values.Add("openid.identity", claimedID) 36 | } 37 | } else { 38 | // 7.3.1. Discovered Information 39 | // If the end user entered an OP Identifier, there is no Claimed Identifier. 40 | // For the purposes of making OpenID Authentication requests, the value 41 | // "http://specs.openid.net/auth/2.0/identifier_select" MUST be used as both the 42 | // Claimed Identifier and the OP-Local Identifier when an OP Identifier is entered. 43 | values.Add("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") 44 | values.Add("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") 45 | } 46 | 47 | if len(realm) > 0 { 48 | values.Add("openid.realm", realm) 49 | } 50 | 51 | if strings.Contains(opEndpoint, "?") { 52 | return opEndpoint + "&" + values.Encode(), nil 53 | } 54 | return opEndpoint + "?" + values.Encode(), nil 55 | } 56 | -------------------------------------------------------------------------------- /redirect_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | ) 7 | 8 | func TestBuildRedirectUrl(t *testing.T) { 9 | expectURL(t, "https://endpoint/a", "opLocalId", "claimedId", "returnTo", "realm", 10 | "https://endpoint/a?"+ 11 | "openid.ns=http://specs.openid.net/auth/2.0"+ 12 | "&openid.mode=checkid_setup"+ 13 | "&openid.return_to=returnTo"+ 14 | "&openid.claimed_id=claimedId"+ 15 | "&openid.identity=opLocalId"+ 16 | "&openid.realm=realm") 17 | // No realm. 18 | expectURL(t, "https://endpoint/a", "opLocalId", "claimedId", "returnTo", "", 19 | "https://endpoint/a?"+ 20 | "openid.ns=http://specs.openid.net/auth/2.0"+ 21 | "&openid.mode=checkid_setup"+ 22 | "&openid.return_to=returnTo"+ 23 | "&openid.claimed_id=claimedId"+ 24 | "&openid.identity=opLocalId") 25 | // No realm, no localId 26 | expectURL(t, "https://endpoint/a", "", "claimedId", "returnTo", "", 27 | "https://endpoint/a?"+ 28 | "openid.ns=http://specs.openid.net/auth/2.0"+ 29 | "&openid.mode=checkid_setup"+ 30 | "&openid.return_to=returnTo"+ 31 | "&openid.claimed_id=claimedId"+ 32 | "&openid.identity=claimedId") 33 | // No realm, no claimedId 34 | expectURL(t, "https://endpoint/a", "opLocalId", "", "returnTo", "", 35 | "https://endpoint/a?"+ 36 | "openid.ns=http://specs.openid.net/auth/2.0"+ 37 | "&openid.mode=checkid_setup"+ 38 | "&openid.return_to=returnTo"+ 39 | "&openid.claimed_id="+ 40 | "http://specs.openid.net/auth/2.0/identifier_select"+ 41 | "&openid.identity="+ 42 | "http://specs.openid.net/auth/2.0/identifier_select") 43 | } 44 | 45 | func expectURL(t *testing.T, opEndpoint, opLocalID, claimedID, returnTo, realm, expected string) { 46 | url, err := BuildRedirectURL(opEndpoint, opLocalID, claimedID, returnTo, realm) 47 | if err != nil { 48 | t.Errorf("Unexpected error: %s", err) 49 | } 50 | compareUrls(t, url, expected) 51 | } 52 | 53 | func TestRedirectWithDiscovery(t *testing.T) { 54 | expected := "foo?" + 55 | "openid.ns=http://specs.openid.net/auth/2.0" + 56 | "&openid.mode=checkid_setup" + 57 | "&openid.return_to=mysite/cb" + 58 | "&openid.claimed_id=" + 59 | "http://specs.openid.net/auth/2.0/identifier_select" + 60 | "&openid.identity=" + 61 | "http://specs.openid.net/auth/2.0/identifier_select" 62 | 63 | // They all redirect to the same XRDS document 64 | expectRedirect(t, "http://example.com/xrds", 65 | "mysite/cb", "", expected, false) 66 | expectRedirect(t, "http://example.com/xrds-loc", 67 | "mysite/cb", "", expected, false) 68 | expectRedirect(t, "http://example.com/xrds-meta", 69 | "mysite/cb", "", expected, false) 70 | } 71 | 72 | func expectRedirect(t *testing.T, uri, callback, realm, exRedirect string, exErr bool) { 73 | redirect, err := testInstance.RedirectURL(uri, callback, realm) 74 | if (err != nil) != exErr { 75 | t.Errorf("Unexpected error: '%s'", err) 76 | return 77 | } 78 | compareUrls(t, redirect, exRedirect) 79 | } 80 | 81 | func compareUrls(t *testing.T, url1, expected string) { 82 | p1, err1 := url.Parse(url1) 83 | p2, err2 := url.Parse(expected) 84 | if err1 != nil { 85 | t.Errorf("Url1 non parsable: %s", err1) 86 | return 87 | } 88 | if err2 != nil { 89 | t.Errorf("ExpectedUrl non parsable: %s", err2) 90 | return 91 | } 92 | if p1.Scheme != p2.Scheme || 93 | p1.Host != p2.Host || 94 | p1.Path != p2.Path { 95 | t.Errorf("URLs don't match: %s vs %s", url1, expected) 96 | } 97 | q1, _ := url.ParseQuery(p1.RawQuery) 98 | q2, _ := url.ParseQuery(p2.RawQuery) 99 | if err := compareQueryParams(q1, q2); err != nil { 100 | t.Errorf("URLs query params don't match: %s: %s vs %s", err, url1, expected) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /verify.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | func Verify(uri string, cache DiscoveryCache, nonceStore NonceStore) (id string, err error) { 12 | return defaultInstance.Verify(uri, cache, nonceStore) 13 | } 14 | 15 | func (oid *OpenID) Verify(uri string, cache DiscoveryCache, nonceStore NonceStore) (id string, err error) { 16 | parsedURL, err := url.Parse(uri) 17 | if err != nil { 18 | return "", err 19 | } 20 | values, err := url.ParseQuery(parsedURL.RawQuery) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | // 11. Verifying Assertions 26 | // When the Relying Party receives a positive assertion, it MUST 27 | // verify the following before accepting the assertion: 28 | 29 | // - The value of "openid.signed" contains all the required fields. 30 | // (Section 10.1) 31 | if err = verifySignedFields(values); err != nil { 32 | return "", err 33 | } 34 | 35 | // - The signature on the assertion is valid (Section 11.4) 36 | if err = verifySignature(uri, values, oid.urlGetter); err != nil { 37 | return "", err 38 | } 39 | 40 | // - The value of "openid.return_to" matches the URL of the current 41 | // request (Section 11.1) 42 | if err = verifyReturnTo(parsedURL, values); err != nil { 43 | return "", err 44 | } 45 | 46 | // - Discovered information matches the information in the assertion 47 | // (Section 11.2) 48 | if err = oid.verifyDiscovered(parsedURL, values, cache); err != nil { 49 | return "", err 50 | } 51 | 52 | // - An assertion has not yet been accepted from this OP with the 53 | // same value for "openid.response_nonce" (Section 11.3) 54 | if err = verifyNonce(values, nonceStore); err != nil { 55 | return "", err 56 | } 57 | 58 | // If all four of these conditions are met, assertion is now 59 | // verified. If the assertion contained a Claimed Identifier, the 60 | // user is now authenticated with that identifier. 61 | return values.Get("openid.claimed_id"), nil 62 | } 63 | 64 | // 10.1. Positive Assertions 65 | // openid.signed - Comma-separated list of signed fields. 66 | // This entry consists of the fields without the "openid." prefix that the signature covers. 67 | // This list MUST contain at least "op_endpoint", "return_to" "response_nonce" and "assoc_handle", 68 | // and if present in the response, "claimed_id" and "identity". 69 | func verifySignedFields(vals url.Values) error { 70 | ok := map[string]bool{ 71 | "op_endpoint": false, 72 | "return_to": false, 73 | "response_nonce": false, 74 | "assoc_handle": false, 75 | "claimed_id": vals.Get("openid.claimed_id") == "", 76 | "identity": vals.Get("openid.identity") == "", 77 | } 78 | signed := strings.Split(vals.Get("openid.signed"), ",") 79 | for _, sf := range signed { 80 | ok[sf] = true 81 | } 82 | for k, v := range ok { 83 | if !v { 84 | return fmt.Errorf("%v must be signed but isn't", k) 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | // 11.1. Verifying the Return URL 91 | // To verify that the "openid.return_to" URL matches the URL that is processing this assertion: 92 | // - The URL scheme, authority, and path MUST be the same between the two 93 | // URLs. 94 | // - Any query parameters that are present in the "openid.return_to" URL 95 | // MUST also be present with the same values in the URL of the HTTP 96 | // request the RP received. 97 | func verifyReturnTo(uri *url.URL, vals url.Values) error { 98 | returnTo := vals.Get("openid.return_to") 99 | rp, err := url.Parse(returnTo) 100 | if err != nil { 101 | return err 102 | } 103 | if uri.Scheme != rp.Scheme || 104 | uri.Host != rp.Host || 105 | uri.Path != rp.Path { 106 | return errors.New( 107 | "Scheme, host or path don't match in return_to URL") 108 | } 109 | qp, err := url.ParseQuery(rp.RawQuery) 110 | if err != nil { 111 | return err 112 | } 113 | return compareQueryParams(qp, vals) 114 | } 115 | 116 | // Any parameter in q1 must also be present in q2, and values must match. 117 | func compareQueryParams(q1, q2 url.Values) error { 118 | for k := range q1 { 119 | v1 := q1.Get(k) 120 | v2 := q2.Get(k) 121 | if v1 != v2 { 122 | return fmt.Errorf( 123 | "URLs query params don't match: Param %s different: %s vs %s", 124 | k, v1, v2) 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | func (oid *OpenID) verifyDiscovered(uri *url.URL, vals url.Values, cache DiscoveryCache) error { 131 | version := vals.Get("openid.ns") 132 | if version != "http://specs.openid.net/auth/2.0" { 133 | return errors.New("Bad protocol version") 134 | } 135 | 136 | endpoint := vals.Get("openid.op_endpoint") 137 | if len(endpoint) == 0 { 138 | return errors.New("missing openid.op_endpoint url param") 139 | } 140 | localID := vals.Get("openid.identity") 141 | if len(localID) == 0 { 142 | return errors.New("no localId to verify") 143 | } 144 | claimedID := vals.Get("openid.claimed_id") 145 | if len(claimedID) == 0 { 146 | // If no Claimed Identifier is present in the response, the 147 | // assertion is not about an identifier and the RP MUST NOT use the 148 | // User-supplied Identifier associated with the current OpenID 149 | // authentication transaction to identify the user. Extension 150 | // information in the assertion MAY still be used. 151 | // --- This library does not support this case. So claimed 152 | // identifier must be present. 153 | return errors.New("no claimed_id to verify") 154 | } 155 | 156 | // 11.2. Verifying Discovered Information 157 | 158 | // If the Claimed Identifier in the assertion is a URL and contains a 159 | // fragment, the fragment part and the fragment delimiter character "#" 160 | // MUST NOT be used for the purposes of verifying the discovered 161 | // information. 162 | claimedIDVerify := claimedID 163 | if fragmentIndex := strings.Index(claimedID, "#"); fragmentIndex != -1 { 164 | claimedIDVerify = claimedID[0:fragmentIndex] 165 | } 166 | 167 | // If the Claimed Identifier is included in the assertion, it 168 | // MUST have been discovered by the Relying Party and the 169 | // information in the assertion MUST be present in the 170 | // discovered information. The Claimed Identifier MUST NOT be an 171 | // OP Identifier. 172 | if discovered := cache.Get(claimedIDVerify); discovered != nil && 173 | discovered.OpEndpoint() == endpoint && 174 | discovered.OpLocalID() == localID && 175 | discovered.ClaimedID() == claimedIDVerify { 176 | return nil 177 | } 178 | 179 | // If the Claimed Identifier was not previously discovered by the 180 | // Relying Party (the "openid.identity" in the request was 181 | // "http://specs.openid.net/auth/2.0/identifier_select" or a different 182 | // Identifier, or if the OP is sending an unsolicited positive 183 | // assertion), the Relying Party MUST perform discovery on the Claimed 184 | // Identifier in the response to make sure that the OP is authorized to 185 | // make assertions about the Claimed Identifier. 186 | if ep, _, _, err := oid.Discover(claimedID); err == nil { 187 | if ep == endpoint { 188 | // This claimed ID points to the same endpoint, therefore this 189 | // endpoint is authorized to make assertions about that claimed ID. 190 | // TODO: There may be multiple endpoints found during discovery. 191 | // They should all be checked. 192 | cache.Put(claimedIDVerify, &SimpleDiscoveredInfo{opEndpoint: endpoint, opLocalID: localID, claimedID: claimedIDVerify}) 193 | return nil 194 | } 195 | } 196 | 197 | return errors.New("Could not verify the claimed ID") 198 | } 199 | 200 | func verifyNonce(vals url.Values, store NonceStore) error { 201 | nonce := vals.Get("openid.response_nonce") 202 | endpoint := vals.Get("openid.op_endpoint") 203 | return store.Accept(endpoint, nonce) 204 | } 205 | 206 | func verifySignature(uri string, vals url.Values, getter httpGetter) error { 207 | // To have the signature verification performed by the OP, the 208 | // Relying Party sends a direct request to the OP. To verify the 209 | // signature, the OP uses a private association that was generated 210 | // when it issued the positive assertion. 211 | 212 | // 11.4.2.1. Request Parameters 213 | params := make(url.Values) 214 | // openid.mode: Value: "check_authentication" 215 | params.Add("openid.mode", "check_authentication") 216 | // Exact copies of all fields from the authentication response, 217 | // except for "openid.mode". 218 | for k, vs := range vals { 219 | if k == "openid.mode" { 220 | continue 221 | } 222 | for _, v := range vs { 223 | params.Add(k, v) 224 | } 225 | } 226 | resp, err := getter.Post(vals.Get("openid.op_endpoint"), params) 227 | if err != nil { 228 | return err 229 | } 230 | defer resp.Body.Close() 231 | content, err := ioutil.ReadAll(resp.Body) 232 | response := string(content) 233 | lines := strings.Split(response, "\n") 234 | 235 | isValid := false 236 | nsValid := false 237 | for _, l := range lines { 238 | if l == "is_valid:true" { 239 | isValid = true 240 | } else if l == "ns:http://specs.openid.net/auth/2.0" { 241 | nsValid = true 242 | } 243 | } 244 | if isValid && nsValid { 245 | // Yay ! 246 | return nil 247 | } 248 | 249 | return errors.New("Could not verify assertion with provider") 250 | } 251 | -------------------------------------------------------------------------------- /verify_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestVerifyNonce(t *testing.T) { 10 | timeStr := time.Now().UTC().Format(time.RFC3339) 11 | ns := NewSimpleNonceStore() 12 | v := url.Values{} 13 | 14 | // Initial values 15 | v.Set("openid.op_endpoint", "1") 16 | v.Set("openid.response_nonce", timeStr+"foo") 17 | if err := verifyNonce(v, ns); err != nil { 18 | t.Errorf("verifyNonce failed unexpectedly: %v", err) 19 | } 20 | 21 | // Different nonce 22 | v.Set("openid.response_nonce", timeStr+"bar") 23 | if err := verifyNonce(v, ns); err != nil { 24 | t.Errorf("verifyNonce failed unexpectedly: %v", err) 25 | } 26 | 27 | // Different endpoint 28 | v.Set("openid.op_endpoint", "2") 29 | if err := verifyNonce(v, ns); err != nil { 30 | t.Errorf("verifyNonce failed unexpectedly: %v", err) 31 | } 32 | } 33 | 34 | func TestVerifySignedFields(t *testing.T) { 35 | // No claimed_id/identity, properly signed 36 | doVerifySignedFields(t, 37 | url.Values{"openid.signed": []string{"signed,op_endpoint,return_to,response_nonce,assoc_handle"}}, 38 | true) 39 | 40 | // Everything properly signed, even empty claimed_id/identity 41 | doVerifySignedFields(t, 42 | url.Values{"openid.signed": []string{"signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle"}}, 43 | true) 44 | 45 | // With claimed_id/identity, properly signed 46 | doVerifySignedFields(t, 47 | url.Values{"openid.signed": []string{"signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle"}, 48 | "openid.claimed_id": []string{"foo"}, 49 | "openid.identity": []string{"foo"}}, 50 | true) 51 | 52 | // With claimed_id/identity, but those two not signed 53 | doVerifySignedFields(t, 54 | url.Values{"openid.signed": []string{"signed,op_endpoint,return_to,response_nonce,assoc_handle"}, 55 | "openid.claimed_id": []string{"foo"}, 56 | "openid.identity": []string{"foo"}}, 57 | false) 58 | 59 | // Missing signature for op_endpoint 60 | doVerifySignedFields(t, 61 | url.Values{"openid.signed": []string{"signed,claimed_id,identity,return_to,response_nonce,assoc_handle"}, 62 | "openid.claimed_id": []string{"foo"}, 63 | "openid.identity": []string{"foo"}}, 64 | false) 65 | 66 | // Missing signature for return_to 67 | doVerifySignedFields(t, 68 | url.Values{"openid.signed": []string{"signed,op_endpoint,claimed_id,identity,response_nonce,assoc_handle"}, 69 | "openid.claimed_id": []string{"foo"}, 70 | "openid.identity": []string{"foo"}}, 71 | false) 72 | 73 | // Missing signature for response_nonce 74 | doVerifySignedFields(t, 75 | url.Values{"openid.signed": []string{"signed,op_endpoint,claimed_id,identity,return_to,assoc_handle"}, 76 | "openid.claimed_id": []string{"foo"}, 77 | "openid.identity": []string{"foo"}}, 78 | false) 79 | 80 | // Missing signature for assoc_handle 81 | doVerifySignedFields(t, 82 | url.Values{"openid.signed": []string{"signed,op_endpoint,claimed_id,identity,return_to,response_nonce"}, 83 | "openid.claimed_id": []string{"foo"}, 84 | "openid.identity": []string{"foo"}}, 85 | false) 86 | } 87 | 88 | func doVerifySignedFields(t *testing.T, v url.Values, succeed bool) { 89 | if err := verifySignedFields(v); err == nil && !succeed { 90 | t.Errorf("verifySignedFields succeeded unexpectedly: %v - %v", v, err) 91 | } else if err != nil && succeed { 92 | t.Errorf("verifySignedFields failed unexpectedly: %v - %v", v, err) 93 | } 94 | } 95 | 96 | func TestVerifyDiscovered(t *testing.T) { 97 | dc := NewSimpleDiscoveryCache() 98 | vals := url.Values{"openid.ns": []string{"http://specs.openid.net/auth/2.0"}, 99 | "openid.mode": []string{"id_res"}, 100 | "openid.op_endpoint": []string{"http://example.com/openid/login"}, 101 | "openid.claimed_id": []string{"http://example.com/openid/id/foo"}, 102 | "openid.identity": []string{"http://example.com/openid/id/foo"}} 103 | 104 | // Make sure we fail with no discovery handler 105 | if err := testInstance.verifyDiscovered(nil, vals, dc); err == nil { 106 | t.Errorf("verifyDiscovered succeeded unexpectedly with no discovery") 107 | } 108 | 109 | // Add the discovery handler 110 | testGetter.urls["http://example.com/openid/id/foo#Accept#application/xrds+xml"] = `HTTP/1.0 200 OK 111 | Content-Type: application/xrds+xml; charset=UTF-8 112 | 113 | 114 | 115 | 116 | 117 | http://specs.openid.net/auth/2.0/signon 118 | http://example.com/openid/login 119 | 120 | 121 | ` 122 | 123 | // Make sure we succeed now 124 | if err := testInstance.verifyDiscovered(nil, vals, dc); err != nil { 125 | t.Errorf("verifyDiscovered failed unexpectedly: %v", err) 126 | } 127 | 128 | // Remove the discovery handler 129 | delete(testGetter.urls, "http://example.com/openid/id/foo#Accept#application/xrds+xml") 130 | 131 | // Make sure we still succeed thanks to the discovery cache 132 | if err := testInstance.verifyDiscovered(nil, vals, dc); err != nil { 133 | t.Errorf("verifyDiscovered failed unexpectedly: %v", err) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /xrds.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // TODO: As per 11.2 in openid 2 specs, a service may have multiple 10 | // URIs. We don't care for discovery really, but we do care for 11 | // verification though. 12 | type XrdsIdentifier struct { 13 | Type []string `xml:"Type"` 14 | URI string `xml:"URI"` 15 | LocalID string `xml:"LocalID"` 16 | Priority int `xml:"priority,attr"` 17 | } 18 | 19 | type Xrd struct { 20 | Service []*XrdsIdentifier `xml:"Service"` 21 | } 22 | 23 | type XrdsDocument struct { 24 | XMLName xml.Name `xml:"XRDS"` 25 | Xrd *Xrd `xml:"XRD"` 26 | } 27 | 28 | func parseXrds(input []byte) (opEndpoint, opLocalID string, err error) { 29 | xrdsDoc := &XrdsDocument{} 30 | err = xml.Unmarshal(input, xrdsDoc) 31 | if err != nil { 32 | return 33 | } 34 | 35 | if xrdsDoc.Xrd == nil { 36 | return "", "", errors.New("XRDS document missing XRD tag") 37 | } 38 | 39 | // 7.3.2.2. Extracting Authentication Data 40 | // Once the Relying Party has obtained an XRDS document, it 41 | // MUST first search the document (following the rules 42 | // described in [XRI_Resolution_2.0]) for an OP Identifier 43 | // Element. If none is found, the RP will search for a Claimed 44 | // Identifier Element. 45 | for _, service := range xrdsDoc.Xrd.Service { 46 | // 7.3.2.1.1. OP Identifier Element 47 | // An OP Identifier Element is an element with the 48 | // following information: 49 | // An tag whose text content is 50 | // "http://specs.openid.net/auth/2.0/server". 51 | // An tag whose text content is the OP Endpoint URL 52 | if service.hasType("http://specs.openid.net/auth/2.0/server") { 53 | opEndpoint = strings.TrimSpace(service.URI) 54 | return 55 | } 56 | } 57 | for _, service := range xrdsDoc.Xrd.Service { 58 | // 7.3.2.1.2. Claimed Identifier Element 59 | // A Claimed Identifier Element is an element 60 | // with the following information: 61 | // An tag whose text content is 62 | // "http://specs.openid.net/auth/2.0/signon". 63 | // An tag whose text content is the OP Endpoint 64 | // URL. 65 | // An tag (optional) whose text content is the 66 | // OP-Local Identifier. 67 | if service.hasType("http://specs.openid.net/auth/2.0/signon") { 68 | opEndpoint = strings.TrimSpace(service.URI) 69 | opLocalID = strings.TrimSpace(service.LocalID) 70 | return 71 | } 72 | } 73 | return "", "", errors.New("Could not find a compatible service") 74 | } 75 | 76 | func (xrdsi *XrdsIdentifier) hasType(tpe string) bool { 77 | for _, t := range xrdsi.Type { 78 | if t == tpe { 79 | return true 80 | } 81 | } 82 | return false 83 | } 84 | -------------------------------------------------------------------------------- /xrds_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestXrds(t *testing.T) { 8 | testExpectOpID(t, []byte(` 9 | 10 | 12 | 13 | 14 | http://openid.net/signon/1.0 15 | http://www.myopenid.com/server 16 | http://smoker.myopenid.com/ 17 | 18 | 19 | http://openid.net/signon/1.0 20 | http://www.livejournal.com/openid/server.bml 21 | 22 | http://www.livejournal.com/users/frank/ 23 | 24 | 25 | 26 | http://lid.netmesh.org/sso/2.0 27 | 28 | 29 | http://specs.openid.net/auth/2.0/server 30 | foo 31 | 32 | 33 | 34 | `), "foo", "") 35 | 36 | testExpectOpID(t, []byte(` 37 | 38 | 40 | 41 | 42 | http://specs.openid.net/auth/2.0/signon 43 | https://www.exampleprovider.com/endpoint/ 44 | https://exampleuser.exampleprovider.com/ 45 | 46 | 47 | 48 | `), 49 | "https://www.exampleprovider.com/endpoint/", 50 | "https://exampleuser.exampleprovider.com/") 51 | 52 | // OP Identifier Element has priority over Claimed Identifier Element 53 | testExpectOpID(t, []byte(` 54 | 55 | 57 | 58 | 59 | http://specs.openid.net/auth/2.0/signon 60 | https://www.exampleprovider.com/endpoint-signon/ 61 | 62 | 63 | http://specs.openid.net/auth/2.0/server 64 | https://www.exampleprovider.com/endpoint-server/ 65 | 66 | 67 | 68 | `), 69 | "https://www.exampleprovider.com/endpoint-server/", 70 | "") 71 | } 72 | 73 | func testExpectOpID(t *testing.T, xrds []byte, op, id string) { 74 | receivedOp, receivedID, err := parseXrds(xrds) 75 | if err != nil { 76 | t.Errorf("Got an error parsing XRDS (%s): %s", string(xrds), err) 77 | } else { 78 | if receivedOp != op { 79 | t.Errorf("Extracted OP does not match: Exepect %s, Got %s", 80 | op, receivedOp) 81 | } 82 | if receivedID != id { 83 | t.Errorf("Extracted ID does not match: Exepect %s, Got %s", 84 | id, receivedID) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /yadis_discovery.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/ioutil" 7 | "strings" 8 | 9 | "golang.org/x/net/html" 10 | ) 11 | 12 | var yadisHeaders = map[string]string{ 13 | "Accept": "application/xrds+xml"} 14 | 15 | func yadisDiscovery(id string, getter httpGetter) (opEndpoint string, opLocalID string, err error) { 16 | // Section 6.2.4 of Yadis 1.0 specifications. 17 | // The Yadis Protocol is initiated by the Relying Party Agent 18 | // with an initial HTTP request using the Yadis URL. 19 | 20 | // This request MUST be either a GET or a HEAD request. 21 | 22 | // A GET or HEAD request MAY include an HTTP Accept 23 | // request-header (HTTP 14.1) specifying MIME media type, 24 | // application/xrds+xml. 25 | resp, err := getter.Get(id, yadisHeaders) 26 | if err != nil { 27 | return "", "", err 28 | } 29 | 30 | defer resp.Body.Close() 31 | 32 | // Section 6.2.5 from Yadis 1.0 spec: Response 33 | 34 | contentType := resp.Header.Get("Content-Type") 35 | 36 | // The response MUST be one of: 37 | // (see 6.2.6 for precedence) 38 | if l := resp.Header.Get("X-XRDS-Location"); l != "" { 39 | // 2. HTTP response-headers that include an X-XRDS-Location 40 | // response-header, together with a document 41 | return getYadisResourceDescriptor(l, getter) 42 | } else if strings.Contains(contentType, "text/html") { 43 | // 1. An HTML document with a element that includes a 44 | // element with http-equiv attribute, X-XRDS-Location, 45 | 46 | metaContent, err := findMetaXrdsLocation(resp.Body) 47 | if err == nil { 48 | return getYadisResourceDescriptor(metaContent, getter) 49 | } 50 | return "", "", err 51 | } else if strings.Contains(contentType, "application/xrds+xml") { 52 | // 4. A document of MIME media type, application/xrds+xml. 53 | body, err := ioutil.ReadAll(resp.Body) 54 | if err == nil { 55 | return parseXrds(body) 56 | } 57 | return "", "", err 58 | } 59 | // 3. HTTP response-headers only, which MAY include an 60 | // X-XRDS-Location response-header, a content-type 61 | // response-header specifying MIME media type, 62 | // application/xrds+xml, or both. 63 | // (this is handled by one of the 2 previous if statements) 64 | return "", "", errors.New("No expected header, or content type") 65 | } 66 | 67 | // Similar as above, but we expect an absolute Yadis document URL. 68 | func getYadisResourceDescriptor(id string, getter httpGetter) (opEndpoint string, opLocalID string, err error) { 69 | resp, err := getter.Get(id, yadisHeaders) 70 | if err != nil { 71 | return "", "", err 72 | } 73 | defer resp.Body.Close() 74 | // 4. A document of MIME media type, application/xrds+xml. 75 | body, err := ioutil.ReadAll(resp.Body) 76 | if err == nil { 77 | return parseXrds(body) 78 | } 79 | return "", "", err 80 | } 81 | 82 | // Search for 83 | // 84 | // 85 | func findMetaXrdsLocation(input io.Reader) (location string, err error) { 86 | tokenizer := html.NewTokenizer(input) 87 | inHead := false 88 | for { 89 | tt := tokenizer.Next() 90 | switch tt { 91 | case html.ErrorToken: 92 | return "", tokenizer.Err() 93 | case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: 94 | tk := tokenizer.Token() 95 | if tk.Data == "head" { 96 | if tt == html.StartTagToken { 97 | inHead = true 98 | } else { 99 | return "", errors.New("Meta X-XRDS-Location not found") 100 | } 101 | } else if inHead && tk.Data == "meta" { 102 | ok := false 103 | content := "" 104 | for _, attr := range tk.Attr { 105 | if attr.Key == "http-equiv" && 106 | strings.ToLower(attr.Val) == "x-xrds-location" { 107 | ok = true 108 | } else if attr.Key == "content" { 109 | content = attr.Val 110 | } 111 | } 112 | if ok && len(content) > 0 { 113 | return content, nil 114 | } 115 | } 116 | } 117 | } 118 | return "", errors.New("Meta X-XRDS-Location not found") 119 | } 120 | -------------------------------------------------------------------------------- /yadis_discovery_test.go: -------------------------------------------------------------------------------- 1 | package openid 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestFindMetaXrdsLocation(t *testing.T) { 9 | searchMeta(t, ` 10 | 11 | 12 | 13 | `, "foo.com", false) 14 | searchMeta(t, ` 15 | 16 | 17 | 18 | 19 | `, "foo.com", false) 20 | } 21 | 22 | func TestMetaXrdsLocationOutsideHead(t *testing.T) { 23 | searchMeta(t, ` 24 | 25 | 26 | `, "", true) 27 | searchMeta(t, ` 28 | 29 | 30 | 31 | `, "", true) 32 | } 33 | 34 | func TestNoMetaXrdsLocation(t *testing.T) { 35 | searchMeta(t, ` 36 | 37 | 38 | `, "", true) 39 | } 40 | 41 | func searchMeta(t *testing.T, doc, loc string, err bool) { 42 | r := bytes.NewReader([]byte(doc)) 43 | res, e := findMetaXrdsLocation(r) 44 | if (e != nil) != err { 45 | t.Errorf("Unexpected error: '%s'", e) 46 | } else if e == nil { 47 | if res != loc { 48 | t.Errorf("Found bad location: Expected %s, Got %s", loc, res) 49 | } 50 | } 51 | } 52 | --------------------------------------------------------------------------------