├── .github └── workflows │ ├── build.yml │ └── build_and_publish.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── go.mod ├── go.sum ├── handlers.go ├── main.go ├── templates.go ├── templates ├── error.tpl ├── index.tpl └── protected.tpl ├── types.go ├── util.go └── util_test.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches-ignore: 5 | - master 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | name: Check out code 13 | 14 | - name: Build the tagged Docker image 15 | run: docker build . --file Dockerfile --tag oidc-app-tester 16 | -------------------------------------------------------------------------------- /.github/workflows/build_and_publish.yml: -------------------------------------------------------------------------------- 1 | name: build-and-deploy 2 | on: 3 | push: 4 | branches: [master] 5 | 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | name: Check out code 12 | 13 | - uses: mr-smithers-excellent/docker-build-push@v5 14 | name: Build & push Docker image 15 | with: 16 | image: oidc-tester-app 17 | registry: ghcr.io 18 | username: ${{ secrets.GHCR_USERNAME }} 19 | password: ${{ secrets.GHCR_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19-alpine AS builder 2 | 3 | WORKDIR /go/src/app 4 | COPY . . 5 | 6 | RUN go get -d -v ./... 7 | RUN go install -v ./... 8 | RUN go build -ldflags '-s -w' -o oidc-tester-app *.go 9 | 10 | FROM alpine 11 | 12 | RUN apk --no-cache add ca-certificates tzdata bash 13 | 14 | WORKDIR /app 15 | 16 | COPY --from=builder /go/src/app/oidc-tester-app oidc-tester-app 17 | 18 | ENV PATH="${PATH}:/app" 19 | 20 | CMD ["oidc-tester-app"] 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Clément Michaud 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidc-tester-app 2 | 3 | oidc-tester-app is an OIDC client used for testing the OIDC API provided by Authelia 4 | 5 | ## License 6 | 7 | This software is licensed under [MIT](./LICENSE.md). 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/authelia/oidc-tester-app 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/coreos/go-oidc/v3 v3.6.0 7 | github.com/gorilla/mux v1.8.0 8 | github.com/gorilla/sessions v1.2.1 9 | github.com/rs/zerolog v1.30.0 10 | github.com/spf13/cobra v1.7.0 11 | github.com/stretchr/testify v1.8.4 12 | golang.org/x/oauth2 v0.11.0 13 | ) 14 | 15 | require ( 16 | github.com/davecgh/go-spew v1.1.1 // indirect 17 | github.com/go-jose/go-jose/v3 v3.0.0 // indirect 18 | github.com/golang/protobuf v1.5.3 // indirect 19 | github.com/gorilla/securecookie v1.1.1 // indirect 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 21 | github.com/mattn/go-colorable v0.1.12 // indirect 22 | github.com/mattn/go-isatty v0.0.14 // indirect 23 | github.com/pmezard/go-difflib v1.0.0 // indirect 24 | github.com/spf13/pflag v1.0.5 // indirect 25 | golang.org/x/crypto v0.12.0 // indirect 26 | golang.org/x/net v0.14.0 // indirect 27 | golang.org/x/sys v0.11.0 // indirect 28 | google.golang.org/appengine v1.6.7 // indirect 29 | google.golang.org/protobuf v1.31.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= 2 | github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= 9 | github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= 10 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 11 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 12 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 13 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 14 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 15 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 16 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 20 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 21 | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 22 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 23 | github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= 24 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 25 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 26 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 27 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 28 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 29 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 30 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 31 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 35 | github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= 36 | github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= 37 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 38 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 39 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 40 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 41 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 45 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 46 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 47 | golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 48 | golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= 49 | golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 50 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 51 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 52 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 53 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 54 | golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= 55 | golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= 56 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 57 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 61 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 63 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 64 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 65 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 66 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 67 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 68 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 69 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 70 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 71 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 72 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 76 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 77 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha512" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/coreos/go-oidc/v3/oidc" 10 | "github.com/gorilla/mux" 11 | "github.com/gorilla/sessions" 12 | "github.com/rs/zerolog/log" 13 | "golang.org/x/oauth2" 14 | ) 15 | 16 | func jsonHandler(res http.ResponseWriter, req *http.Request) { 17 | res.Header().Add("Content-Type", "application/json") 18 | session, err := store.Get(req, options.CookieName) 19 | 20 | if err != nil { 21 | writeErr(res, err, "error getting session", http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | claims := session.Values["id_token"].(Claims) 26 | 27 | if err = json.NewEncoder(res).Encode(claims); err != nil { 28 | writeErr(res, err, "error encoding claims", http.StatusInternalServerError) 29 | return 30 | } 31 | } 32 | 33 | func indexHandler(res http.ResponseWriter, req *http.Request) { 34 | session, err := store.Get(req, options.CookieName) 35 | 36 | if err != nil { 37 | writeErr(res, err, "error getting session", http.StatusInternalServerError) 38 | return 39 | } 40 | 41 | tpl := indexTplData{ 42 | Error: req.FormValue("error"), 43 | } 44 | 45 | if logged, ok := session.Values["logged"].(bool); ok && logged { 46 | tpl.LoggedIn = true 47 | tpl.Claims.IDToken = session.Values["id_token"].(Claims) 48 | tpl.Claims.UserInfo = session.Values["userinfo"].(Claims) 49 | 50 | if len(options.GroupsFilter) >= 1 { 51 | for _, group := range tpl.Claims.UserInfo.Groups { 52 | if isStringInSlice(group, options.GroupsFilter) { 53 | tpl.Groups = append(tpl.Groups, filterText(group, options.Filters)) 54 | } 55 | } 56 | } else { 57 | tpl.Groups = filterSliceOfText(tpl.Claims.UserInfo.Groups, options.Filters) 58 | } 59 | 60 | tpl.Claims.IDToken.PreferredUsername = filterText(tpl.Claims.IDToken.PreferredUsername, options.Filters) 61 | tpl.Claims.UserInfo.PreferredUsername = filterText(tpl.Claims.UserInfo.PreferredUsername, options.Filters) 62 | tpl.Claims.IDToken.Audience = filterSliceOfText(tpl.Claims.IDToken.Audience, options.Filters) 63 | tpl.Claims.UserInfo.Audience = filterSliceOfText(tpl.Claims.UserInfo.Audience, options.Filters) 64 | tpl.Claims.IDToken.Issuer = filterText(tpl.Claims.IDToken.Issuer, options.Filters) 65 | tpl.Claims.UserInfo.Issuer = filterText(tpl.Claims.UserInfo.Issuer, options.Filters) 66 | tpl.Claims.IDToken.Email = filterText(tpl.Claims.IDToken.Email, options.Filters) 67 | tpl.Claims.UserInfo.Email = filterText(tpl.Claims.UserInfo.Email, options.Filters) 68 | tpl.Claims.IDToken.Name = filterText(tpl.Claims.IDToken.Name, options.Filters) 69 | tpl.Claims.UserInfo.Name = filterText(tpl.Claims.UserInfo.Name, options.Filters) 70 | tpl.RawToken = rawTokens[tpl.Claims.IDToken.JWTIdentifier] 71 | tpl.AuthorizeCodeURL = acURLs[tpl.Claims.IDToken.JWTIdentifier].String() 72 | } 73 | 74 | res.Header().Add("Content-Type", "text/html") 75 | 76 | if err = indexTpl.Execute(res, tpl); err != nil { 77 | writeErr(res, err, "error executing index template", http.StatusInternalServerError) 78 | } 79 | } 80 | 81 | func errorHandler(res http.ResponseWriter, req *http.Request) { 82 | tpl := errorTplData{ 83 | Error: req.FormValue("error"), 84 | ErrorDescription: req.FormValue("error_description"), 85 | ErrorURI: req.FormValue("error_uri"), 86 | State: req.FormValue("state"), 87 | } 88 | 89 | log.Logger.Error(). 90 | Str("error_name", tpl.Error). 91 | Str("description", tpl.ErrorDescription). 92 | Str("uri", tpl.ErrorURI). 93 | Str("state", tpl.State). 94 | Msg("received oidc authorization server error") 95 | 96 | res.Header().Add("Content-Type", "text/html") 97 | 98 | if err := errorTpl.Execute(res, tpl); err != nil { 99 | writeErr(res, err, "error executing error template", http.StatusInternalServerError) 100 | } 101 | } 102 | 103 | func protectedHandler(basic bool) http.HandlerFunc { 104 | return func(res http.ResponseWriter, req *http.Request) { 105 | session, err := store.Get(req, options.CookieName) 106 | 107 | if err != nil { 108 | writeErr(res, err, "error getting session", http.StatusInternalServerError) 109 | return 110 | } 111 | 112 | if logged, ok := session.Values["logged"].(bool); !ok || !logged { 113 | session.Values["redirect-url"] = req.URL.Path 114 | 115 | if err = session.Save(req, res); err != nil { 116 | writeErr(res, err, "error saving session", http.StatusInternalServerError) 117 | return 118 | } 119 | 120 | http.Redirect(res, req, oauth2Config.AuthCodeURL("random-string-here"), http.StatusFound) 121 | 122 | return 123 | } 124 | 125 | tpl := protectedTplData{} 126 | 127 | vars := mux.Vars(req) 128 | 129 | tpl.Vars.Type = vars["type"] 130 | tpl.Vars.Value = vars["name"] 131 | 132 | if basic { 133 | tpl.Vars.ProtectedSecret = "2511140547" 134 | tpl.Vars.Type = "basic" 135 | } else { 136 | tpl.Claims = session.Values["id_token"].(Claims) 137 | hash := sha512.New() 138 | 139 | hash.Write([]byte(tpl.Vars.Value)) 140 | 141 | tpl.Vars.ProtectedSecret = fmt.Sprintf("%x", hash.Sum(nil)) 142 | } 143 | 144 | res.Header().Add("Content-Type", "text/html") 145 | 146 | if err = protectedTpl.Execute(res, tpl); err != nil { 147 | writeErr(res, err, "error executing template", http.StatusInternalServerError) 148 | } 149 | } 150 | } 151 | 152 | func loginHandler(res http.ResponseWriter, req *http.Request) { 153 | session, err := store.Get(req, options.CookieName) 154 | 155 | if err != nil { 156 | writeErr(res, nil, "error getting cookie", http.StatusInternalServerError) 157 | return 158 | } 159 | 160 | session.Values["redirect-url"] = "/" 161 | if err = session.Save(req, res); err != nil { 162 | writeErr(res, err, "error saving session", http.StatusInternalServerError) 163 | return 164 | } 165 | 166 | http.Redirect(res, req, oauth2Config.AuthCodeURL("random-string-here"), http.StatusFound) 167 | } 168 | 169 | func logoutHandler(res http.ResponseWriter, req *http.Request) { 170 | session, err := store.Get(req, options.CookieName) 171 | if err != nil { 172 | writeErr(res, err, "error getting cookie", http.StatusInternalServerError) 173 | return 174 | } 175 | 176 | // Reset the session 177 | session.Values = make(map[interface{}]interface{}) 178 | 179 | if err = session.Save(req, res); err != nil { 180 | writeErr(res, err, "error saving session", http.StatusInternalServerError) 181 | return 182 | } 183 | 184 | http.Redirect(res, req, "/", http.StatusFound) 185 | } 186 | 187 | func oauthCallbackHandler(res http.ResponseWriter, req *http.Request) { 188 | if req.FormValue("error") != "" { 189 | http.Redirect(res, req, fmt.Sprintf("/error?%s", req.Form.Encode()), http.StatusFound) 190 | 191 | return 192 | } 193 | 194 | var ( 195 | token *oauth2.Token 196 | idToken *oidc.IDToken 197 | err error 198 | idTokenRaw string 199 | ok bool 200 | ) 201 | 202 | // The state should be checked here in production 203 | if token, err = oauth2Config.Exchange(req.Context(), req.URL.Query().Get("code")); err != nil { 204 | writeErr(res, err, "unable to exchange authorization code for tokens", http.StatusInternalServerError) 205 | return 206 | } 207 | 208 | // Extract the ID Token from OAuth2 token. 209 | if idTokenRaw, ok = token.Extra("id_token").(string); !ok { 210 | writeErr(res, nil, "missing id token", http.StatusInternalServerError) 211 | return 212 | } 213 | 214 | // Parse and verify ID Token payload. 215 | if idToken, err = verifier.Verify(req.Context(), idTokenRaw); err != nil { 216 | writeErr(res, err, "unable to verify id token or token is invalid", http.StatusInternalServerError) 217 | return 218 | } 219 | 220 | // Extract custom claims 221 | claimsIDToken := Claims{} 222 | 223 | if err = idToken.Claims(&claimsIDToken); err != nil { 224 | writeErr(res, err, "unable to decode id token claims", http.StatusInternalServerError) 225 | return 226 | } 227 | 228 | var userinfo *oidc.UserInfo 229 | 230 | if userinfo, err = provider.UserInfo(req.Context(), oauth2.StaticTokenSource(token)); err != nil { 231 | writeErr(res, err, "unable to retrieve userinfo claims", http.StatusInternalServerError) 232 | return 233 | } 234 | 235 | claimsUserInfo := Claims{} 236 | 237 | if err = userinfo.Claims(&claimsUserInfo); err != nil { 238 | writeErr(res, err, "unable to decode userinfo claims", http.StatusInternalServerError) 239 | return 240 | } 241 | 242 | var session *sessions.Session 243 | 244 | if session, err = store.Get(req, options.CookieName); err != nil { 245 | writeErr(res, err, "unable to get session from cookie", http.StatusInternalServerError) 246 | return 247 | } 248 | 249 | session.Values["id_token"] = claimsIDToken 250 | session.Values["userinfo"] = claimsUserInfo 251 | session.Values["logged"] = true 252 | rawTokens[claimsIDToken.JWTIdentifier] = idTokenRaw 253 | acURLs[claimsIDToken.JWTIdentifier] = req.URL 254 | 255 | if err = session.Save(req, res); err != nil { 256 | writeErr(res, err, "unable to save session", http.StatusInternalServerError) 257 | return 258 | } 259 | 260 | var redirectUrl string 261 | 262 | if redirectUrl, ok = session.Values["redirect-url"].(string); ok { 263 | http.Redirect(res, req, redirectUrl, http.StatusFound) 264 | return 265 | } 266 | 267 | http.Redirect(res, req, "/", http.StatusFound) 268 | } 269 | 270 | func writeErr(res http.ResponseWriter, err error, msg string, statusCode int) { 271 | switch { 272 | case err == nil: 273 | log.Logger.Error(). 274 | Msg(msg) 275 | 276 | http.Error(res, msg, statusCode) 277 | default: 278 | log.Logger.Error(). 279 | Err(err). 280 | Msg(msg) 281 | 282 | http.Error(res, fmt.Errorf("%s: %w", msg, err).Error(), statusCode) 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "encoding/gob" 7 | "fmt" 8 | "github.com/gorilla/sessions" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strings" 13 | 14 | "github.com/coreos/go-oidc/v3/oidc" 15 | "github.com/gorilla/mux" 16 | "github.com/rs/zerolog" 17 | "github.com/rs/zerolog/log" 18 | "github.com/spf13/cobra" 19 | "golang.org/x/oauth2" 20 | ) 21 | 22 | var options Options 23 | 24 | var ( 25 | provider *oidc.Provider 26 | oauth2Config oauth2.Config 27 | verifier *oidc.IDTokenVerifier 28 | store = sessions.NewCookieStore([]byte("secret-key")) 29 | ) 30 | 31 | var ( 32 | rawTokens = make(map[string]string) 33 | acURLs = make(map[string]*url.URL) 34 | ) 35 | 36 | func main() { 37 | log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}).With().Timestamp().Logger().Level(zerolog.DebugLevel) 38 | 39 | gob.Register(Claims{}) 40 | 41 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 42 | 43 | rootCmd := &cobra.Command{Use: "oidc-tester-app", RunE: root} 44 | 45 | rootCmd.Flags().StringVar(&options.Host, "host", "0.0.0.0", "Specifies the tcp host to listen on") 46 | rootCmd.Flags().IntVar(&options.Port, "port", 8080, "Specifies the port to listen on") 47 | rootCmd.Flags().StringVar(&options.PublicURL, "public-url", "http://localhost:8080/", "Specifies the root URL to generate the redirect URI") 48 | rootCmd.Flags().StringVar(&options.ClientID, "id", "", "Specifies the OpenID Connect Client ID") 49 | rootCmd.Flags().StringVarP(&options.ClientSecret, "secret", "s", "", "Specifies the OpenID Connect Client Secret") 50 | rootCmd.Flags().StringVarP(&options.Issuer, "issuer", "i", "", "Specifies the URL for the OpenID Connect OP") 51 | rootCmd.Flags().StringVar(&options.Scopes, "scopes", "openid,profile,email,groups", "Specifies the OpenID Connect scopes to request") 52 | rootCmd.Flags().StringVar(&options.CookieName, "cookie-name", "oidc-client", "Specifies the storage cookie name to use") 53 | rootCmd.Flags().StringSliceVar(&options.Filters, "filters", []string{}, "If specified filters the specified text from html output (not json) out of the email addresses, display names, audience, etc") 54 | rootCmd.Flags().StringSliceVar(&options.GroupsFilter, "groups-filter", []string{}, "If specified only shows the groups in this list") 55 | 56 | _ = rootCmd.MarkFlagRequired("id") 57 | _ = rootCmd.MarkFlagRequired("secret") 58 | _ = rootCmd.MarkFlagRequired("issuer") 59 | 60 | if err := rootCmd.Execute(); err != nil { 61 | log.Logger.Fatal().Err(err).Msg("error in root process") 62 | } 63 | } 64 | 65 | func root(cmd *cobra.Command, args []string) (err error) { 66 | var ( 67 | publicURL, redirectURL *url.URL 68 | ) 69 | 70 | if publicURL, redirectURL, err = getURLs(options.PublicURL); err != nil { 71 | return fmt.Errorf("could not parse public url: %w", err) 72 | } 73 | 74 | log.Info(). 75 | Str("provider_url", options.Issuer). 76 | Str("redirect_url", redirectURL.String()). 77 | Msg("configuring oidc provider") 78 | 79 | if provider, err = oidc.NewProvider(context.Background(), options.Issuer); err != nil { 80 | return fmt.Errorf("error initializing oidc provider: %w", err) 81 | } 82 | 83 | verifier = provider.Verifier(&oidc.Config{ClientID: options.ClientID}) 84 | oauth2Config = oauth2.Config{ 85 | ClientID: options.ClientID, 86 | ClientSecret: options.ClientSecret, 87 | RedirectURL: redirectURL.String(), 88 | Endpoint: provider.Endpoint(), 89 | Scopes: strings.Split(options.Scopes, ","), 90 | } 91 | 92 | r := mux.NewRouter() 93 | r.HandleFunc("/", indexHandler) 94 | r.HandleFunc("/error", errorHandler) 95 | r.HandleFunc("/login", loginHandler) 96 | r.HandleFunc("/logout", logoutHandler) 97 | r.HandleFunc("/oauth2/callback", oauthCallbackHandler) 98 | r.HandleFunc("/json", jsonHandler) 99 | r.HandleFunc("/jwt.json", jsonHandler) 100 | r.HandleFunc("/protected", protectedHandler(true)) 101 | r.HandleFunc("/protected/{type:group|user}/{name}", protectedHandler(false)) 102 | 103 | r.NotFoundHandler = &ErrorHandler{http.StatusNotFound} 104 | r.MethodNotAllowedHandler = &ErrorHandler{http.StatusMethodNotAllowed} 105 | 106 | log.Logger.Info(). 107 | Str("host", options.Host). 108 | Int("port", options.Port). 109 | Str("address", publicURL.String()). 110 | Msg("listening for connections") 111 | 112 | if err = http.ListenAndServe(fmt.Sprintf("%s:%d", options.Host, options.Port), r); err != nil { 113 | return fmt.Errorf("error listening: %w", err) 114 | } 115 | 116 | return nil 117 | } 118 | 119 | type ErrorHandler struct { 120 | code int 121 | } 122 | 123 | func (h *ErrorHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 124 | switch h.code { 125 | case http.StatusNotFound: 126 | fmt.Printf("404 Not Found: %s %s\n", r.Method, r.URL) 127 | case http.StatusMethodNotAllowed: 128 | fmt.Printf("405 Method Not Allowed: %s %s\n", r.Method, r.URL) 129 | } 130 | 131 | rw.WriteHeader(h.code) 132 | } 133 | -------------------------------------------------------------------------------- /templates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "html/template" 7 | "strings" 8 | ) 9 | 10 | //go:embed templates/* 11 | var templateFS embed.FS 12 | 13 | var ( 14 | indexTpl, protectedTpl, errorTpl *template.Template 15 | 16 | templateFuncMap = template.FuncMap{ 17 | "stringsJoin": strings.Join, 18 | "stringsEqualFold": strings.EqualFold, 19 | "isStringInSlice": isStringInSlice, 20 | } 21 | ) 22 | 23 | type indexTplData struct { 24 | Title, Description, RawToken string 25 | 26 | Error string 27 | LoggedIn bool 28 | Claims tplClaims 29 | Groups []string 30 | AuthorizeCodeURL string 31 | } 32 | 33 | type tplClaims struct { 34 | IDToken Claims 35 | UserInfo Claims 36 | } 37 | 38 | type protectedTplData struct { 39 | Title, Description string 40 | Vars struct { 41 | Type, Value, ProtectedSecret string 42 | } 43 | Claims Claims 44 | } 45 | 46 | type errorTplData struct { 47 | Title, Description string 48 | 49 | Error, ErrorDescription, ErrorURI, State string 50 | } 51 | 52 | func init() { 53 | indexTpl = templateMustLoadAndParse("index") 54 | protectedTpl = templateMustLoadAndParse("protected") 55 | errorTpl = templateMustLoadAndParse("error") 56 | } 57 | 58 | func templateMustLoadAndParse(name string) *template.Template { 59 | if data, err := templateFS.ReadFile(fmt.Sprintf("templates/%s.tpl", name)); err != nil { 60 | panic(err) 61 | } else { 62 | t, err := template.New(name).Funcs(templateFuncMap).Parse(string(data)) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | return t 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /templates/error.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ or .Title "Authelia OIDC app" }} 9 | {{ if not (eq .Description "") }}{{ end }} 10 | 11 | 12 | 13 | 14 | {{ if not (eq .Description "") }}{{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |

Home

23 |

Log in

24 |

{{ .State }}

25 |

{{ .Error }}

26 | {{- if .ErrorDescription }} 27 |

{{ .ErrorDescription }}

28 | {{- end }} 29 | {{- if .ErrorURI }} 30 |

{{ .ErrorURI }}

31 | {{- end }} 32 |
33 | 34 | 35 | -------------------------------------------------------------------------------- /templates/index.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ or .Title "Authelia OIDC app" }} 9 | {{ if not (eq .Description "") }}{{ end }} 10 | 11 | 12 | 13 | 14 | {{ if not (eq .Description "") }}{{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | {{- if .LoggedIn }} 23 |

Logged in as {{ or .Claims.UserInfo.PreferredUsername .Claims.IDToken.Subject "unknown" }}!

24 |

Log out

25 |

Access Token Hash: {{ .Claims.IDToken.AccessTokenHash }}

26 |

Code Hash: {{ .Claims.IDToken.CodeHash }}

27 |

Authentication Context Class Reference: {{ .Claims.IDToken.AuthenticationContextClassReference }}

28 |

Authentication Methods Reference: {{ stringsJoin .Claims.IDToken.AuthenticationMethodsReference ", " }}

29 |

Audience: {{ stringsJoin .Claims.IDToken.Audience ", " }}

30 |

Expires: {{ .Claims.IDToken.Expires }}

31 |

Issue Time: {{ .Claims.IDToken.IssueTime }}

32 |

Requested At: {{ .Claims.IDToken.RequestedAt }}

33 |

Authorize Time: {{ .Claims.IDToken.AuthorizeTime }}

34 |

Not Before: {{ .Claims.IDToken.NotBefore }}

35 |

Issuer: {{ .Claims.IDToken.Issuer }}

36 |

JWT ID: {{ .Claims.IDToken.JWTIdentifier }}

37 |

Subject: {{ .Claims.IDToken.Subject }}

38 |

Nonce: {{ .Claims.IDToken.Nonce }}

39 |

Name: {{ .Claims.UserInfo.Name }}

40 |

Name (ID Token): {{ .Claims.IDToken.Name }}

41 |

Given Name: {{ .Claims.UserInfo.GivenName }}

42 |

Given Name (ID Token): {{ .Claims.IDToken.GivenName }}

43 |

Family Name: {{ .Claims.UserInfo.FamilyName }}

44 |

Family Name (ID Token): {{ .Claims.IDToken.FamilyName }}

45 |

Middle Name: {{ .Claims.UserInfo.MiddleName }}

46 |

Middle Name (ID Token): {{ .Claims.IDToken.MiddleName }}

47 |

Nickname: {{ .Claims.UserInfo.Nickname }}

48 |

Nickname (ID Token): {{ .Claims.IDToken.Nickname }}

49 |

Preferred Username: {{ .Claims.UserInfo.PreferredUsername }}

50 |

Preferred Username (ID Token): {{ .Claims.IDToken.PreferredUsername }}

51 |

Profile: {{ .Claims.UserInfo.Profile }}

52 |

Profile (ID Token): {{ .Claims.IDToken.Profile }}

53 |

Website: {{ .Claims.UserInfo.Website }}

54 |

Website (ID Token): {{ .Claims.IDToken.Website }}

55 |

Gender: {{ .Claims.UserInfo.Gender }}

56 |

Gender (ID Token): {{ .Claims.IDToken.Gender }}

57 |

Birthdate: {{ .Claims.UserInfo.Birthdate }}

58 |

Birthdate (ID Token): {{ .Claims.IDToken.Birthdate }}

59 |

ZoneInfo: {{ .Claims.UserInfo.ZoneInfo }}

60 |

ZoneInfo (ID Token): {{ .Claims.IDToken.ZoneInfo }}

61 |

Locale: {{ .Claims.UserInfo.Locale }}

62 |

Locale (ID Token): {{ .Claims.IDToken.Locale }}

63 |

Updated At: {{ .Claims.UserInfo.UpdatedAt }}

64 |

Updated At (ID Token): {{ .Claims.IDToken.UpdatedAt }}

65 |

Email: {{ .Claims.UserInfo.Email }}

66 |

Email (ID Token): {{ .Claims.IDToken.Email }}

67 |

Email Alts: {{ .Claims.UserInfo.EmailAlts }}

68 |

Email Alts (ID Token): {{ .Claims.IDToken.EmailAlts }}

69 |

Email Verified: {{ .Claims.UserInfo.EmailVerified }}

70 |

Email Verified (ID Token): {{ .Claims.IDToken.EmailVerified }}

71 |

Phone Number: {{ .Claims.UserInfo.PhoneNumber }}

72 |

Phone Number (ID Token): {{ .Claims.IDToken.PhoneNumber }}

73 |

Phone Number Verified: {{ .Claims.UserInfo.PhoneNumberVerified }}

74 |

Phone Number Verified (ID Token): {{ .Claims.IDToken.PhoneNumberVerified }}

75 |

Groups: {{ stringsJoin .Groups ", " }}

76 |

Groups (ID Token): {{ stringsJoin .Groups ", " }}

77 |

Raw: {{ .RawToken }}

78 |

Authorize Code URL: {{ .AuthorizeCodeURL }}

79 | {{- else }} 80 |

Not logged yet...

Log in 81 | {{- end }} 82 |
83 | 84 | 85 | -------------------------------------------------------------------------------- /templates/protected.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ or .Title "Authelia OIDC app" }} 9 | {{ if not (eq .Description "") }}{{ end }} 10 | 11 | 12 | 13 | 14 | {{ if not (eq .Description "") }}{{ end }} 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | {{- if eq .Vars.Type "user" }} 23 |

This is the protected user endpoint

24 | {{- if stringsEqualFold .Claims.IDToken.PreferredUsername .Vars.Value }} 25 |

Access Granted. Your username is '{{ .Claims.IDToken.PreferredUsername }}'.

26 |

1

27 |

{{ .Vars.ProtectedSecret }}

28 | {{- else }} 29 |

Access Denied. Requires user '{{ .Vars.Value }}'.

30 |

0

31 | {{- end }} 32 | {{- else if eq .Vars.Type "group" }} 33 |

This is the protected group endpoint

34 | {{- if (isStringInSlice .Vars.Value .Claims.IDToken.Groups) }} 35 |

Access Granted. You have the group '{{ .Vars.Value }}'.

36 |

1

37 |

{{ .Vars.ProtectedSecret }}

38 | {{- else }} 39 |

Access Denied. Requires group '{{ .Vars.Value }}'.

40 |

0

41 | {{- end }} 42 | {{- else if eq .Vars.Type "basic" }} 43 |

This is the protected endpoint

44 |

{{ .Vars.ProtectedSecret }}

45 | {{- else }} 46 |

This is the protected invalid endpoint

47 | {{- end }} 48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Claims struct { 4 | JWTIdentifier string `json:"jti"` 5 | Issuer string `json:"iss"` 6 | Subject string `json:"sub"` 7 | Nonce string `json:"nonce"` 8 | Expires int64 `json:"exp"` 9 | IssueTime int64 `json:"iat"` 10 | RequestedAt int64 `json:"rat"` 11 | AuthorizeTime int64 `json:"auth_time"` 12 | NotBefore int64 `json:"nbf"` 13 | Audience []string `json:"aud"` 14 | Scope []string `json:"scp"` 15 | ScopeString string `json:"scope"` 16 | AccessTokenHash string `json:"at_hash"` 17 | CodeHash string `json:"c_hash"` 18 | AuthenticationContextClassReference string `json:"acr"` 19 | AuthenticationMethodsReference []string `json:"amr"` 20 | 21 | Name string `json:"name"` 22 | GivenName string `json:"given_name"` 23 | FamilyName string `json:"family_name"` 24 | MiddleName string `json:"middle_name"` 25 | Nickname string `json:"nickname"` 26 | PreferredUsername string `json:"preferred_username"` 27 | Profile string `jsoon:"profile"` 28 | Picture string `json:"picture"` 29 | Website string `json:"website"` 30 | Gender string `json:"gender"` 31 | Birthdate string `json:"birthdate"` 32 | ZoneInfo string `json:"zoneinfo"` 33 | Locale string `json:"locale"` 34 | UpdatedAt int64 `json:"updated_at"` 35 | Email string `json:"email"` 36 | EmailAlts []string `json:"alt_emails"` 37 | EmailVerified bool `json:"email_verified"` 38 | PhoneNumber string `json:"phone_number"` 39 | PhoneNumberVerified bool `json:"phone_number_verified"` 40 | Address ClamsAddress `json:"address"` 41 | Groups []string `json:"groups"` 42 | } 43 | 44 | type ClamsAddress struct { 45 | StreetAddress string `json:"street_address"` 46 | Locality string `json:"locality"` 47 | Region string `json:"region"` 48 | PostalCode string `json:"postal_code"` 49 | Country string `json:"country"` 50 | } 51 | 52 | type Options struct { 53 | Host string 54 | Port int 55 | ClientID string 56 | ClientSecret string 57 | Issuer string 58 | PublicURL string 59 | Scopes string 60 | CookieName string 61 | Filters []string 62 | GroupsFilter []string 63 | } 64 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "path" 7 | "strings" 8 | ) 9 | 10 | func isStringInSlice(s string, slice []string) bool { 11 | for _, x := range slice { 12 | if s == x { 13 | return true 14 | } 15 | } 16 | 17 | return false 18 | } 19 | 20 | func filterText(input string, filters []string) (output string) { 21 | if len(filters) == 0 { 22 | return input 23 | } 24 | 25 | for _, filter := range filters { 26 | input = strings.Replace(input, filter, strings.Repeat("*", len(filter)), -1) 27 | } 28 | 29 | return input 30 | } 31 | 32 | func filterSliceOfText(input []string, filters []string) (output []string) { 33 | for _, item := range input { 34 | output = append(output, filterText(item, filters)) 35 | } 36 | 37 | return output 38 | } 39 | 40 | func getURLs(rootURL string) (publicURL *url.URL, redirectURL *url.URL, err error) { 41 | if publicURL, err = url.Parse(rootURL); err != nil { 42 | return nil, nil, err 43 | } 44 | 45 | if publicURL.Scheme != "http" && publicURL.Scheme != "https" { 46 | return nil, nil, fmt.Errorf("scheme must be http or https but it is '%s'", publicURL.Scheme) 47 | } 48 | 49 | if !strings.HasSuffix(publicURL.Path, "/") { 50 | publicURL.Path += "/" 51 | } 52 | 53 | redirectURL = &url.URL{} 54 | *redirectURL = *publicURL 55 | redirectURL.Path = path.Join(redirectURL.Path, "/oauth2/callback") 56 | 57 | return publicURL, redirectURL, nil 58 | } 59 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestGetURLs(t *testing.T) { 12 | var ( 13 | public, redirect *url.URL 14 | err error 15 | ) 16 | 17 | public, redirect, err = getURLs("https://app.example.com") 18 | 19 | assert.NoError(t, err) 20 | require.NotNil(t, public) 21 | require.NotNil(t, redirect) 22 | 23 | assert.Equal(t, "https://app.example.com/", public.String()) 24 | assert.Equal(t, "https://app.example.com/oauth2/callback", redirect.String()) 25 | 26 | public, redirect, err = getURLs("https://app.example.com/") 27 | 28 | assert.NoError(t, err) 29 | require.NotNil(t, public) 30 | require.NotNil(t, redirect) 31 | 32 | assert.Equal(t, "https://app.example.com/", public.String()) 33 | assert.Equal(t, "https://app.example.com/oauth2/callback", redirect.String()) 34 | 35 | public, redirect, err = getURLs("https://app.example.com:5050/") 36 | 37 | assert.NoError(t, err) 38 | require.NotNil(t, public) 39 | require.NotNil(t, redirect) 40 | 41 | assert.Equal(t, "https://app.example.com:5050/", public.String()) 42 | assert.Equal(t, "https://app.example.com:5050/oauth2/callback", redirect.String()) 43 | 44 | public, redirect, err = getURLs("app.example.com") 45 | 46 | assert.EqualError(t, err, "scheme must be http or https but it is ''") 47 | assert.Nil(t, public) 48 | assert.Nil(t, redirect) 49 | } 50 | --------------------------------------------------------------------------------