├── .dockerignore ├── .github └── workflows │ ├── fly-deploy.yml │ └── push.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── ssokenizer │ ├── main.go │ └── serve.go ├── context.go ├── docs ├── sequence_diagram.svg └── sequence_diagram.txt ├── etc └── ssokenizer.yml ├── fly.toml ├── go.mod ├── go.sum ├── musickit ├── musickit.go ├── static │ └── musickit.js └── templates │ └── start.html ├── oauth2 ├── oauth2.go └── oauth2_test.go ├── provider.go ├── ssokenizer.go ├── transaction.go └── vanta └── vanta.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /.git 2 | /.envrc 3 | -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-go@v3 19 | - run: go test -v ./... 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | vendor 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine as builder 2 | 3 | WORKDIR /src/ssokenizer 4 | COPY . . 5 | 6 | ARG SSOKENIZER_VERSION= 7 | ARG SSOKENIZER_COMMIT= 8 | 9 | RUN --mount=type=cache,target=/root/.cache/go-build \ 10 | --mount=type=cache,target=/go/pkg \ 11 | go build -ldflags "-X 'main.Version=${SSOKENIZER_VERSION}' -X 'main.Commit=${SSOKENIZER_COMMIT}'" -buildvcs=false -o /usr/local/bin/ssokenizer ./cmd/ssokenizer 12 | 13 | 14 | FROM alpine 15 | COPY --from=builder /usr/local/bin/ssokenizer /usr/local/bin/ssokenizer 16 | 17 | RUN apk add ca-certificates 18 | 19 | ADD etc/ssokenizer.yml /etc/ssokenizer.yml 20 | 21 | ENTRYPOINT ["ssokenizer", "serve"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: deploy 2 | deploy: 3 | fly deploy --build-arg SSOKENIZER_VERSION=0.0.1 --build-arg SSOKENIZER_COMMIT=$(shell git rev-parse HEAD) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssokenizer 2 | 3 | Ssokenizer provides a layer of abstraction for applications wanting to authenticate users and access 3rd party APIs via OAuth, but not wanting to directly handle users' API tokens. Ssokenizer is responsible for performing the OAuth dance, obtaining the user's OAuth access token. The token is then encrypted for use with the [tokenizer](https://github.com/superfly/tokenizer) HTTP proxy. By delegating OAuth authentication to ssokenizer and access token usage to tokenizer, applications limit the risk of tokens being lost, stolen, or misused. 4 | 5 | ![](/docs/sequence_diagram.svg) 6 | 7 | ## Configuration 8 | 9 | Ssokenizer searches for a configuration file at `/etc/ssokenizer.yml`, but a path can be specified with the `-config` flag. See [`/etc/ssokenizer.yml`](/etc/ssokenizer.yml) in this repo for an annotated example configuration. Environment variables in the configuration file are expanded when the application is booting. 10 | 11 | ## Deploying to fly.io 12 | 13 | - Fork this repository on GitHub 14 | - Clone the forked repository 15 | - In a terminal, enter the repository directory and run `fly launch` 16 | - Follow the prompts, declining to deploy the new application. 17 | - Set secrets (`TOKENIZER_SEAL_KEY`, `PROXY_AUTH`, `_CLIENT_SECRET`) by running `fly secrets set --stage SECRET_NAME="SECRET_VALUE"` 18 | - Edit the configuration file in [`/etc/ssokenizer.yml`](/etc/ssokenizer.yml) to reflect the OAuth providers you would like to support. The secrets you set in the previous step will be available as environment variables in this configuration file. 19 | - Run `make deploy` or `fly deploy` 20 | 21 | ## Usage 22 | 23 | To start the authentication flow, navigate users to `https:////start?state=`. The `` and `` will depend on your configuration and how you've deployed the app. The `state` parameter is used to prevent login-CSRF attacks. Your application should generate a random string and associate it with the user's session by either putting it in the session-store provided by your web framework or by putting it directly in a cookie. 24 | 25 | The user will now perform the OAuth dance with ssokenizer and the identity provider. Upon successful completion, the user will be redirected back to your configured `return_url` with several parameters: 26 | 27 | - `sealed` - The sealed OAuth access token and refresh token (if applicable), ready for use with your tokenizer deployment. 28 | - `expires` - The unix epoch time when the access token will expire, if applicable. 29 | - `state` - The state parameter that you passed to the `/start` URL. It is important for your application to verify that this matches the state value you stored in the user's session or cookie. 30 | 31 | If the OAuth dance doesn't finish successfully, an `error` parameter will be added to the configured `return_url` instead of the `sealed` and `expired` parameter. 32 | 33 | ### Using the sealed token 34 | 35 | You are now ready to communicate with the provider API using the sealed token via the tokenizer HTTP proxy. You'll need to send the sealed token in the `Proxy-Tokenizer` header. You'll need to send the configured `proxy_authorization` secret in the `Proxy-Authorization` with the `Bearer` authorization scheme. Remember that requests made via tokenizer must use HTTP instead of HTTPS. 36 | 37 | The following demonstrates how you might call the Google "userinfo" endpoint using cURL: 38 | 39 | ```shell 40 | curl \ 41 | -x $TOKENIZER_URL \ 42 | -H "Proxy-Authorization: Bearer $PROXY_AUTH" \ 43 | -H "Proxy-Tokenizer: $SEALED_TOKEN" \ 44 | http://openidconnect.googleapis.com/v1/userinfo 45 | ``` 46 | 47 | ### Refreshing access tokens 48 | 49 | Some identity providers issue access tokens that expire quickly along with refresh tokens that can be used to fetch new access tokens. To fetch a new access token, send a request to `https:////refresh` via tokenizer. Include the sealed token in the `Proxy-Tokenizer` header, including a `st=refresh` parameter in the header. The response body will contain the new token, sealed for use with tokenizer and the Cache-Control header will contain the seconds until the token expires. 50 | 51 | The following demonstrates how you might refresh a token using cURL: 52 | 53 | ```shell 54 | curl \ 55 | -x $TOKENIZER_URL \ 56 | -H "Proxy-Authorization: Bearer $PROXY_AUTH" \ 57 | -H "Proxy-Tokenizer: $SEALED_TOKEN; st=refresh" \ 58 | http://$SSOKENIZER_HOSTNAME/$PROVIDER_NAME/refresh 59 | ``` -------------------------------------------------------------------------------- /cmd/ssokenizer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/hex" 7 | "errors" 8 | "flag" 9 | "fmt" 10 | "net/url" 11 | "os" 12 | "regexp" 13 | "strings" 14 | 15 | "github.com/superfly/ssokenizer" 16 | "github.com/superfly/ssokenizer/musickit" 17 | "github.com/superfly/ssokenizer/oauth2" 18 | "github.com/superfly/ssokenizer/vanta" 19 | "github.com/superfly/tokenizer" 20 | xoauth2 "golang.org/x/oauth2" 21 | "golang.org/x/oauth2/amazon" 22 | "golang.org/x/oauth2/bitbucket" 23 | "golang.org/x/oauth2/facebook" 24 | "golang.org/x/oauth2/github" 25 | "golang.org/x/oauth2/gitlab" 26 | "golang.org/x/oauth2/google" 27 | "golang.org/x/oauth2/heroku" 28 | "golang.org/x/oauth2/microsoft" 29 | "golang.org/x/oauth2/slack" 30 | "gopkg.in/yaml.v3" 31 | ) 32 | 33 | var ( 34 | Version string 35 | Commit string 36 | ) 37 | 38 | func main() { 39 | if err := Run(context.Background(), os.Args[1:]); err == flag.ErrHelp { 40 | os.Exit(2) 41 | } else if err != nil { 42 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) 43 | os.Exit(1) 44 | } 45 | } 46 | 47 | // Run is the main entry into the binary execution. 48 | func Run(ctx context.Context, args []string) error { 49 | fmt.Println(VersionString()) 50 | 51 | // Extract command name. 52 | var cmd string 53 | if len(args) > 0 { 54 | cmd, args = args[0], args[1:] 55 | } 56 | 57 | switch cmd { 58 | case "serve": 59 | return NewServeCommand().Run(args) 60 | case "version": 61 | fmt.Println(VersionString()) 62 | return nil 63 | 64 | default: 65 | if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") { 66 | printUsage() 67 | return flag.ErrHelp 68 | } 69 | return fmt.Errorf("ssokenizer %s: unknown command", cmd) 70 | } 71 | } 72 | 73 | type Config struct { 74 | // Full URL of the ssokenizer service 75 | URL string `yaml:"url"` 76 | 77 | // Tokenizer seal (public) key 78 | SealKey string `yaml:"seal_key"` 79 | 80 | // Where to return user after auth dance. If present, the string `:name` is 81 | // replaced with the provider name. Can also be specified per-provider. 82 | ReturnURL string `yaml:"return_url"` 83 | 84 | SecretAuth SecretAuthConfig `yaml:"secret_auth"` 85 | Log LogConfig `yaml:"log"` 86 | HTTP HTTPConfig `yaml:"http"` 87 | IdentityProviders map[string]IdentityProviderConfig `yaml:"identity_providers"` 88 | 89 | // fields populated during validation 90 | ssokenizerURL *url.URL 91 | globalTAC tokenizer.AuthConfig 92 | globalReturnURL *url.URL 93 | providers ssokenizer.StaticProviderRegistry 94 | } 95 | 96 | func (c *Config) validate() error { 97 | var err error 98 | 99 | if c.HTTP.Address == "" { 100 | return errors.New("missing http.address") 101 | } 102 | 103 | c.ssokenizerURL, err = url.Parse(c.URL) 104 | switch { 105 | case c.URL == "": 106 | return errors.New("missing url") 107 | case err != nil: 108 | return fmt.Errorf("invalid URL (%q): %w", c.URL, err) 109 | case c.ssokenizerURL.Scheme == "" || c.ssokenizerURL.Host == "": 110 | return fmt.Errorf("malformed URL: %q", c.URL) 111 | case c.ssokenizerURL.Path == "": 112 | c.ssokenizerURL.Path = "/" 113 | } 114 | 115 | if c.SealKey == "" { 116 | return errors.New("missing seal_key") 117 | } 118 | 119 | c.globalTAC, err = c.SecretAuth.tokenizerAuthConfig() 120 | if err != nil { 121 | return err 122 | } 123 | 124 | if c.ReturnURL != "" { 125 | switch c.globalReturnURL, err = url.Parse(c.ReturnURL); { 126 | case err != nil: 127 | return err 128 | case c.globalReturnURL.Scheme == "" || c.globalReturnURL.Host == "": 129 | return fmt.Errorf("malformed return_url: %q", c.ReturnURL) 130 | } 131 | } 132 | 133 | c.providers = make(ssokenizer.StaticProviderRegistry) 134 | for name, pc := range c.IdentityProviders { 135 | if _, dup := c.providers[name]; dup { 136 | return fmt.Errorf("duplicate identity provider %q", name) 137 | } 138 | 139 | provider, err := pc.provider(name, c) 140 | if err != nil { 141 | return fmt.Errorf("invalid identity provider %q: %w", name, err) 142 | } 143 | 144 | c.providers[name] = provider 145 | } 146 | 147 | return nil 148 | } 149 | 150 | // tokenizerHostValidator returns validators that tokenizer can run to only 151 | // allow tokens to be forwarded to specific hosts. In addition to whatever 152 | // hostname pattern we want to allow for a given provider, we also include our 153 | // own hostname so tokenizer can send us requests for refresh tokens. 154 | func (c *Config) tokenizerHostValidator(pattern string) []tokenizer.RequestValidator { 155 | re := regexp.MustCompile(fmt.Sprintf("^(%s|%s)$", regexp.QuoteMeta(c.ssokenizerURL.Hostname()), pattern)) 156 | return []tokenizer.RequestValidator{tokenizer.AllowHostPattern(re)} 157 | } 158 | 159 | // Specifies what authentication clients should be required to present to 160 | // tokenizer in order to use sealed secrets. 161 | type SecretAuthConfig struct { 162 | // The plain string that clients must pass in the Proxy-Authorization 163 | // header. 164 | Bearer string `yaml:"bearer"` 165 | 166 | // Hex SHA256 digest of string that clients must pass in the 167 | // Proxy-Authorization header. 168 | BearerDigest string `yaml:"bearer_digest"` 169 | } 170 | 171 | func (c SecretAuthConfig) tokenizerAuthConfig() (tokenizer.AuthConfig, error) { 172 | switch { 173 | case c.Bearer != "" && c.BearerDigest != "": 174 | return nil, errors.New("bearer and bearer_digest are mutually exclusive") 175 | case c.Bearer != "": 176 | return tokenizer.NewBearerAuthConfig(c.Bearer), nil 177 | case c.BearerDigest != "": 178 | d, err := hex.DecodeString(c.BearerDigest) 179 | if err != nil { 180 | return nil, err 181 | } 182 | return &tokenizer.BearerAuthConfig{Digest: d}, nil 183 | default: 184 | return nil, nil 185 | } 186 | } 187 | 188 | // NewConfig returns a new instance of Config with defaults set. 189 | func NewConfig() Config { 190 | var config Config 191 | return config 192 | } 193 | 194 | type LogConfig struct { 195 | Debug bool `yaml:"debug"` 196 | } 197 | 198 | type HTTPConfig struct { 199 | // address for http server to listen on 200 | Address string `yaml:"address"` 201 | } 202 | 203 | type IdentityProviderConfig struct { 204 | // idb profile name (e.g. google) 205 | Profile string `yaml:"profile"` 206 | 207 | // oauth client ID 208 | ClientID string `yaml:"client_id"` 209 | 210 | // oauth client secret 211 | ClientSecret string `yaml:"client_secret"` 212 | 213 | // oauth scopes to request. Can be specified as a space-separated list of strings. 214 | Scopes []string `yaml:"scopes"` 215 | 216 | // Where to return user after auth dance. Can also be specified globally. 217 | ReturnURL string `yaml:"return_url"` 218 | 219 | // oauth authorization endpoint URL. Only needed for "oauth" profile 220 | AuthURL string `yaml:"auth_url"` 221 | 222 | // oauth token endpoint URL. Only needed for "oauth" profile 223 | TokenURL string `yaml:"token_url"` 224 | 225 | // Apple MusicKit developer token. Only needed for "musickit" profile 226 | MusicKitDeveloperToken string `yaml:"developer_token"` 227 | 228 | SecretAuth SecretAuthConfig `yaml:"secret_auth"` 229 | } 230 | 231 | func (ic *IdentityProviderConfig) provider(name string, c *Config) (ssokenizer.Provider, error) { 232 | pc := ssokenizer.ProviderConfig{ 233 | Tokenizer: ssokenizer.TokenizerConfig{ 234 | SealKey: c.SealKey, 235 | }, 236 | URL: *c.ssokenizerURL.JoinPath("/" + name), 237 | } 238 | 239 | switch tac, err := ic.SecretAuth.tokenizerAuthConfig(); { 240 | case err != nil: 241 | return nil, err 242 | case tac == nil && c.globalTAC == nil: 243 | return nil, errors.New("missing secret_auth") 244 | case tac == nil: 245 | pc.Tokenizer.Auth = c.globalTAC 246 | default: 247 | pc.Tokenizer.Auth = tac 248 | } 249 | 250 | switch { 251 | case ic.ReturnURL == "" && c.globalReturnURL == nil: 252 | return nil, errors.New("missing return_url") 253 | case ic.ReturnURL == "": 254 | pc.ReturnURL = *c.globalReturnURL 255 | default: 256 | switch u, err := url.Parse(ic.ReturnURL); { 257 | case err != nil: 258 | return nil, fmt.Errorf("invalid return_url: %w", err) 259 | case u.Scheme == "" || u.Host == "": 260 | return nil, fmt.Errorf("malformed return_url: %q", ic.ReturnURL) 261 | default: 262 | pc.ReturnURL = *u 263 | } 264 | } 265 | 266 | switch ic.Profile { 267 | case "musickit": 268 | return ic.musicKitProvider(pc, c) 269 | default: 270 | return ic.oauthProvider(pc, c) 271 | } 272 | } 273 | 274 | func (ic *IdentityProviderConfig) musicKitProvider(pc ssokenizer.ProviderConfig, c *Config) (ssokenizer.Provider, error) { 275 | if ic.MusicKitDeveloperToken == "" { 276 | return nil, errors.New("missing developer_token") 277 | } 278 | 279 | pc.Tokenizer.RequestValidators = c.tokenizerHostValidator(`api\.music\.apple\.com`) 280 | 281 | return &musickit.Provider{ 282 | ProviderConfig: pc, 283 | DeveloperToken: ic.MusicKitDeveloperToken, 284 | }, nil 285 | } 286 | 287 | func (ic *IdentityProviderConfig) oauthProvider(pc ssokenizer.ProviderConfig, c *Config) (ssokenizer.Provider, error) { 288 | switch { 289 | case ic.ClientID == "": 290 | return nil, errors.New("missing client_id") 291 | case ic.ClientSecret == "": 292 | return nil, errors.New("missing client_secret") 293 | } 294 | 295 | op := oauth2.Provider{ 296 | ProviderConfig: pc, 297 | OAuthConfig: xoauth2.Config{ 298 | ClientID: ic.ClientID, 299 | ClientSecret: ic.ClientSecret, 300 | Scopes: ic.Scopes, 301 | }, 302 | } 303 | 304 | switch ic.Profile { 305 | case "vanta": 306 | op.OAuthConfig.Endpoint = xoauth2.Endpoint{ 307 | AuthURL: "https://app.vanta.com/oauth/authorize", 308 | TokenURL: "https://api.vanta.com/oauth/token", 309 | AuthStyle: xoauth2.AuthStyleInParams, 310 | } 311 | 312 | op.ForwardParams = []string{"source_id"} 313 | 314 | return &vanta.Provider{Provider: op}, nil 315 | case "oauth": 316 | switch { 317 | case ic.AuthURL == "": 318 | return nil, errors.New("missing auth_url") 319 | case ic.TokenURL == "": 320 | return nil, errors.New("missing token_url") 321 | } 322 | 323 | op.OAuthConfig.Endpoint = xoauth2.Endpoint{ 324 | AuthURL: ic.AuthURL, 325 | TokenURL: ic.TokenURL, 326 | } 327 | 328 | return &op, nil 329 | case "amazon": 330 | op.OAuthConfig.Endpoint = amazon.Endpoint 331 | return &op, nil 332 | case "bitbucket": 333 | op.OAuthConfig.Endpoint = bitbucket.Endpoint 334 | return &op, nil 335 | case "facebook": 336 | op.OAuthConfig.Endpoint = facebook.Endpoint 337 | return &op, nil 338 | case "github": 339 | op.OAuthConfig.Endpoint = github.Endpoint 340 | op.Tokenizer.RequestValidators = c.tokenizerHostValidator(`api\.github\.com`) 341 | return &op, nil 342 | case "gitlab": 343 | op.OAuthConfig.Endpoint = gitlab.Endpoint 344 | return &op, nil 345 | case "google": 346 | op.OAuthConfig.Endpoint = google.Endpoint 347 | op.Tokenizer.RequestValidators = c.tokenizerHostValidator(`.*\.googleapis\.com`) 348 | op.ForwardParams = []string{"hd"} 349 | return &op, nil 350 | case "heroku": 351 | op.OAuthConfig.Endpoint = heroku.Endpoint 352 | op.Tokenizer.RequestValidators = c.tokenizerHostValidator(`api\.heroku\.com`) 353 | return &op, nil 354 | case "microsoft": 355 | op.OAuthConfig.Endpoint = microsoft.LiveConnectEndpoint 356 | return &op, nil 357 | case "slack": 358 | op.OAuthConfig.Endpoint = slack.Endpoint 359 | return &op, nil 360 | default: 361 | return nil, errors.New("unknown identity provider profile") 362 | } 363 | } 364 | 365 | // UnmarshalConfig unmarshals config from data. Expands variables as needed. 366 | func UnmarshalConfig(config *Config, data []byte) error { 367 | // Expand environment variables. 368 | data = []byte(os.ExpandEnv(string(data))) 369 | 370 | dec := yaml.NewDecoder(bytes.NewReader(data)) 371 | dec.KnownFields(true) // strict checking 372 | return dec.Decode(&config) 373 | } 374 | 375 | func VersionString() string { 376 | // Print version & commit information, if available. 377 | if Version != "" { 378 | return fmt.Sprintf("ssokenizer %s, commit=%s", Version, Commit) 379 | } else if Commit != "" { 380 | return fmt.Sprintf("ssokenizer commit=%s", Commit) 381 | } 382 | return "ssokenizer development build" 383 | } 384 | 385 | // printUsage prints the help screen to STDOUT. 386 | func printUsage() { 387 | fmt.Println(` 388 | ssokenizer is a SSO service. 389 | 390 | Usage: 391 | 392 | ssokenizer [arguments] 393 | 394 | The commands are: 395 | 396 | serve runs the server 397 | version prints the version 398 | `[1:]) 399 | } 400 | -------------------------------------------------------------------------------- /cmd/ssokenizer/serve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/sirupsen/logrus" 13 | "github.com/superfly/ssokenizer" 14 | ) 15 | 16 | const gracefulShutdownTimeout = 5 * time.Second 17 | 18 | type ServeCommand struct { 19 | Config Config 20 | } 21 | 22 | // NewServeCommand returns a new instance of ServeCommand. 23 | func NewServeCommand() *ServeCommand { 24 | return &ServeCommand{ 25 | Config: NewConfig(), 26 | } 27 | } 28 | 29 | func (c *ServeCommand) Run(args []string) error { 30 | fs := flag.NewFlagSet("ssokenizer-serve", flag.ContinueOnError) 31 | configPath := fs.String("config", "/etc/ssokenizer.yml", "config file path") 32 | debug := fs.Bool("debug", false, "enable debug logging") 33 | fs.Usage = func() { 34 | fmt.Println(` 35 | The serve command will run ssokenizer. It will read configuration from the 36 | ssokenizer.yml file. This file should in /etc/ssokenizer.yml or specified 37 | with the -config flag. 38 | 39 | Usage: 40 | 41 | ssokenizer serve [arguments] 42 | 43 | Arguments: 44 | `[1:]) 45 | fs.PrintDefaults() 46 | fmt.Println("") 47 | } 48 | if err := fs.Parse(args); err != nil { 49 | return err 50 | } else if fs.NArg() > 0 { 51 | return fmt.Errorf("too many arguments") 52 | } 53 | 54 | // Read configuration. 55 | buf, err := os.ReadFile(*configPath) 56 | if err != nil { 57 | return err 58 | } else if err := UnmarshalConfig(&c.Config, buf); err != nil { 59 | return err 60 | } 61 | if err := c.Config.validate(); err != nil { 62 | return err 63 | } 64 | 65 | // Override debug logging, if set. 66 | if *debug { 67 | c.Config.Log.Debug = true 68 | } 69 | logrus.SetLevel(logrus.DebugLevel) 70 | 71 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 72 | defer cancel() 73 | 74 | server := ssokenizer.NewServer(c.Config.providers) 75 | if err := server.Start(c.Config.HTTP.Address); err != nil { 76 | return err 77 | } 78 | 79 | logrus.Infof("listening at %s", server.Address) 80 | 81 | select { 82 | case <-server.Done: 83 | logrus.Warn("early shutdown") 84 | return server.Err 85 | case <-ctx.Done(): 86 | logrus.Info("received signal. starting shutdown") 87 | 88 | ctx, cancel = context.WithTimeout(context.Background(), gracefulShutdownTimeout) 89 | defer cancel() 90 | 91 | ctx, cancel = signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) 92 | defer cancel() 93 | 94 | return server.Shutdown(ctx) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package ssokenizer 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type contextKey string 11 | 12 | const ( 13 | contextKeyProvider contextKey = "provider" 14 | contextKeyLog contextKey = "log" 15 | ) 16 | 17 | func WithProvider(r *http.Request, p Provider) *http.Request { 18 | return r.WithContext(context.WithValue(r.Context(), contextKeyProvider, p)) 19 | } 20 | 21 | func GetProvider(r *http.Request) Provider { 22 | return r.Context().Value(contextKeyProvider).(Provider) 23 | } 24 | 25 | // Updates the logrus.FieldLogger in the context with added data. Requests are 26 | // logged by Transaction.ReturnData/ReturnError. 27 | func WithLog(r *http.Request, l logrus.FieldLogger) *http.Request { 28 | return r.WithContext(context.WithValue(r.Context(), contextKeyLog, l)) 29 | } 30 | 31 | // Updates the logrus.FieldLogger in the context with "error" field. Requests 32 | // are logged by Transaction.ReturnData/ReturnError. 33 | func WithError(r *http.Request, err error) *http.Request { 34 | return WithLog(r, GetLog(r).WithError(err)) 35 | } 36 | 37 | // Updates the logrus.FieldLogger in the context with added field. Requests 38 | // are logged by Transaction.ReturnData/ReturnError. 39 | func WithField(r *http.Request, key string, value any) *http.Request { 40 | return WithLog(r, GetLog(r).WithField(key, value)) 41 | } 42 | 43 | // Updates the logrus.FieldLogger in the context with added fields. Requests 44 | // are logged by Transaction.ReturnData/ReturnError. 45 | func WithFields(r *http.Request, fields logrus.Fields) *http.Request { 46 | return WithLog(r, GetLog(r).WithFields(fields)) 47 | } 48 | 49 | // Gets the logrus.FieldLogger from the context. Requests are logged by 50 | // Transaction.ReturnData/ReturnError. 51 | func GetLog(r *http.Request) logrus.FieldLogger { 52 | if l, ok := r.Context().Value(contextKeyLog).(logrus.FieldLogger); ok { 53 | return l 54 | } 55 | return logrus.StandardLogger() 56 | } 57 | -------------------------------------------------------------------------------- /docs/sequence_diagram.svg: -------------------------------------------------------------------------------- 1 | %2F%2F%20https%3A%2F%2Fsequencediagram.org%2F%0Atitle%20SSO%20Flow%0A%0Aparticipant%20user%0Aparticipant%20my%20app%0Aparticipant%20tokenizer%0Aparticipant%20ssokenizer%0Aparticipant%20google%0A%0Auser-%3Emy%20app%3A%20GET%20%2Flogin%2Fgoogle%0Amy%20app-%3Euser%3A%20301%20%E2%86%92%20https%3A%2F%2Fssokenizer%2Fgoogle%2Fstart%0Auser-%3Essokenizer%3A%20GET%20%2Fgoogle%2Fstart%0Assokenizer-%3Euser%3A%20301%20%E2%86%92%20https%3A%2F%2Fgoogle%2Fauth%3Fclient_id%3D...%0Auser-%3Egoogle%3A%20GET%20%2Fauth%3Fclient_id%3Dxxx%0Agoogle-%3Egoogle%3A%20auth%20user%5Cncheck%20consent%0Agoogle-%3Euser%3A%20301%20%E2%86%92%20https%3A%2F%2Fssokenizer%2Fcallback%3Fcode%3D111%0Auser-%3Essokenizer%3A%20%20GET%20%2Fcallback%3Fcode%3D111%0Assokenizer-%3Egoogle%3A%20POST%20%2Ftoken%5Cnw%2Fcode%2C%20client_id%2C%20client_secret%0Agoogle-%3Essokenizer%3A%20200%5Cn%7Baccess_token%3A222%2C%20refresh_token%3A333%7D%0Assokenizer-%3Essokenizer%3A%20seal(%7Baccess_token%3A222%2C%20refresh_token%3A333%7D)%0Assokenizer-%3Euser%3A%20301%20%E2%86%92%20https%3A%2F%2Fmy%20app%2Fcallback%3Fdata%3Dsealed-token%0Auser-%3Emy%20app%3A%20GET%20%2Fcallback%3Fdata%3Dsealed-token%0Amy%20app-%3Etokenizer%3A%20GET%20https%3A%2F%2Fgoogle%2Fuser%5Cnw%2Fsealed-token%0Atokenizer-%3Etokenizer%3A%20open(sealed-token)%0Atokenizer-%3Egoogle%3A%20GET%20%2Fuser%5Cnw%2Faccess_token%0Agoogle-%3Etokenizer%3A%20200%5Cn%7Buser%3A%20...%7D%0Atokenizer-%3Emy%20app%3A%20200%5Cn%7Buser%3A%20...%7D%0Amy%20app-%3Euser%3A%20200%5CnSet-Cookie%3A%20user%3D...%0A%3D%3Dtime%20passes.%20access_token%20expires%3D%3D%0Amy%20app-%3Etokenizer%3AGET%20https%3A%2F%2Fssokenizer%2Fgoogle%2Frefresh%5Cnw%2Fsealed-token%0Atokenizer-%3Etokenizer%3A%20open(sealed-token)%0Atokenizer-%3Essokenizer%3A%20GET%20%2Fgoogle%2Frefresh%5Cnw%2Frefresh_token%0Assokenizer-%3Egoogle%3A%20POST%20%2Ftoken%5Cnw%2Frefresh_token%2Cclient_id%2Cclient_secret%0Agoogle-%3Essokenizer%3A%20200%5Cn%7Baccess_token%3A777%2C%20refresh_token%3A888%7D%0Assokenizer-%3Essokenizer%3A%20seal(%7Baccess_token%3A777%2C%20refresh_token%3A888%7D)%0Assokenizer-%3Emy%20app%3A%20200%5Cnsealed_token%0ASSO Flowusermy apptokenizerssokenizergoogleGET /login/google301 → https://ssokenizer/google/startGET /google/start301 → https://google/auth?client_id=...GET /auth?client_id=xxxauth usercheck consent301 → https://ssokenizer/callback?code=111 GET /callback?code=111POST /tokenw/code, client_id, client_secret200{access_token:222, refresh_token:333}seal({access_token:222, refresh_token:333})301 → https://my app/callback?data=sealed-tokenGET /callback?data=sealed-tokenGET https://google/userw/sealed-tokenopen(sealed-token)GET /userw/access_token200{user: ...}200{user: ...}200Set-Cookie: user=...time passes. access_token expiresGET https://ssokenizer/google/refreshw/sealed-tokenopen(sealed-token)GET /google/refreshw/refresh_tokenPOST /tokenw/refresh_token,client_id,client_secret200{access_token:777, refresh_token:888}seal({access_token:777, refresh_token:888})200sealed_token -------------------------------------------------------------------------------- /docs/sequence_diagram.txt: -------------------------------------------------------------------------------- 1 | // https://sequencediagram.org/ 2 | title SSO Flow 3 | 4 | participant user 5 | participant my app 6 | participant tokenizer 7 | participant ssokenizer 8 | participant google 9 | 10 | user->my app: GET /login/google 11 | my app->user: 301 → https://ssokenizer/google/start 12 | user->ssokenizer: GET /google/start 13 | ssokenizer->user: 301 → https://google/auth?client_id=... 14 | user->google: GET /auth?client_id=xxx 15 | google->google: auth user\ncheck consent 16 | google->user: 301 → https://ssokenizer/callback?code=111 17 | user->ssokenizer: GET /callback?code=111 18 | ssokenizer->google: POST /token\nw/code, client_id, client_secret 19 | google->ssokenizer: 200\n{access_token:222, refresh_token:333} 20 | ssokenizer->ssokenizer: seal({access_token:222, refresh_token:333}) 21 | ssokenizer->user: 301 → https://my app/callback?data=sealed-token 22 | user->my app: GET /callback?data=sealed-token 23 | my app->tokenizer: GET https://google/user\nw/sealed-token 24 | tokenizer->tokenizer: open(sealed-token) 25 | tokenizer->google: GET /user\nw/access_token 26 | google->tokenizer: 200\n{user: ...} 27 | tokenizer->my app: 200\n{user: ...} 28 | my app->user: 200\nSet-Cookie: user=... 29 | ==time passes. access_token expires== 30 | my app->tokenizer:GET https://ssokenizer/google/refresh\nw/sealed-token 31 | tokenizer->tokenizer: open(sealed-token) 32 | tokenizer->ssokenizer: GET /google/refresh\nw/refresh_token 33 | ssokenizer->google: POST /token\nw/refresh_token,client_id,client_secret 34 | google->ssokenizer: 200\n{access_token:777, refresh_token:888} 35 | ssokenizer->ssokenizer: seal({access_token:777, refresh_token:888}) 36 | ssokenizer->my app: 200\nsealed_token 37 | -------------------------------------------------------------------------------- /etc/ssokenizer.yml: -------------------------------------------------------------------------------- 1 | # Full URL of the ssokenizer service 2 | url: "$SSOKENIZER_URL" 3 | 4 | # Public part of tokenizer's keypair 5 | seal_key: "$TOKENIZER_SEAL_KEY" 6 | 7 | http: 8 | # Where ssokenizer should listen 9 | address: ":$PORT" 10 | 11 | # Where users will be sent with the sealed token after the authenticating. The 12 | # string `:name` will be replaced with the provider name. The string `:profile` 13 | # will be replaced with the name of the provider profile. This can also be 14 | # specified on individual providers bellow. 15 | # 16 | # return_url: "https://my.app/sso/:profile/:name/callback" 17 | 18 | identity_providers: 19 | # `google` here is the provider name. Users will go to 20 | # https:////start to start the oauth dance 21 | google: 22 | # Apps using sealed secrets with tokenizer will put this token in the 23 | # `Proxy-Authorization` header. 24 | secret_auth: 25 | bearer: "$PROXY_AUTH" 26 | 27 | # amazon, bitbucket, facebook, github, gitlab, google, heroku, microsoft, 28 | # slack, or oauth. 29 | profile: google 30 | 31 | # OAuth Client ID - received from identity provider 32 | client_id: "$GOOGLE_CLIENT_ID" 33 | 34 | # OAuth Client Secret - received from identity provider 35 | client_secret: "$GOOGLE_CLIENT_SECRET" 36 | 37 | # where user should be sent with sealed OAuth token after authenticating 38 | return_url: "$GOOGLE_RETURN_URL" 39 | 40 | # OAuth scopes to request. Separate scopes with spaces to specify multiple 41 | # scopes in the same line. This makes pulling them from environment 42 | # variables easier. 43 | scopes: 44 | - "$GOOGLE_SCOPES" 45 | 46 | # These fields only need to be specified when using a custom "oauth" 47 | # provider profile. 48 | # 49 | # auth_url: $PROVIDER_AUTH_URL 50 | # token_url: $PROVIDER_TOKEN_URL 51 | 52 | github: 53 | secret_auth: 54 | bearer: "$PROXY_AUTH" 55 | profile: github 56 | client_id: "$GITHUB_CLIENT_ID" 57 | client_secret: "$GITHUB_CLIENT_SECRET" 58 | return_url: "$GITHUB_RETURN_URL" 59 | scopes: 60 | - "$GITHUB_SCOPES" 61 | 62 | heroku: 63 | secret_auth: 64 | bearer: "$PROXY_AUTH" 65 | profile: heroku 66 | client_id: "$HEROKU_CLIENT_ID" 67 | client_secret: "$HEROKU_CLIENT_SECRET" 68 | return_url: "$HEROKU_RETURN_URL" 69 | scopes: 70 | - "$HEROKU_SCOPES" 71 | 72 | # Same configurations except for name and return_url to allow authentication 73 | # to staging environments with same OAuth client. 74 | google_staging: 75 | secret_auth: 76 | bearer: "$PROXY_AUTH" 77 | profile: google 78 | client_id: "$GOOGLE_CLIENT_ID" 79 | client_secret: "$GOOGLE_CLIENT_SECRET" 80 | return_url: "$GOOGLE_STAGING_RETURN_URL" 81 | scopes: 82 | - "$GOOGLE_SCOPES" 83 | 84 | google_staging_2: 85 | secret_auth: 86 | bearer: "$PROXY_AUTH" 87 | profile: google 88 | client_id: "$GOOGLE_CLIENT_ID" 89 | client_secret: "$GOOGLE_CLIENT_SECRET" 90 | return_url: "$GOOGLE_STAGING_2_RETURN_URL" 91 | scopes: 92 | - "$GOOGLE_SCOPES" 93 | 94 | github_staging: 95 | secret_auth: 96 | bearer: "$PROXY_AUTH" 97 | profile: github 98 | client_id: "$GITHUB_CLIENT_ID" 99 | client_secret: "$GITHUB_CLIENT_SECRET" 100 | return_url: "$GITHUB_STAGING_RETURN_URL" 101 | scopes: 102 | - "$GITHUB_SCOPES" 103 | 104 | github_staging_2: 105 | secret_auth: 106 | bearer: "$PROXY_AUTH" 107 | profile: github 108 | client_id: "$GITHUB_CLIENT_ID" 109 | client_secret: "$GITHUB_CLIENT_SECRET" 110 | return_url: "$GITHUB_STAGING_2_RETURN_URL" 111 | scopes: 112 | - "$GITHUB_SCOPES" 113 | 114 | heroku_staging: 115 | secret_auth: 116 | bearer: "$PROXY_AUTH" 117 | profile: heroku 118 | client_id: "$HEROKU_CLIENT_ID" 119 | client_secret: "$HEROKU_CLIENT_SECRET" 120 | return_url: "$HEROKU_STAGING_RETURN_URL" 121 | scopes: 122 | - "$HEROKU_SCOPES" 123 | 124 | heroku_staging_2: 125 | secret_auth: 126 | bearer: "$PROXY_AUTH" 127 | profile: heroku 128 | client_id: "$HEROKU_CLIENT_ID" 129 | client_secret: "$HEROKU_CLIENT_SECRET" 130 | return_url: "$HEROKU_STAGING_2_RETURN_URL" 131 | scopes: 132 | - "$HEROKU_SCOPES" 133 | 134 | google_auth: 135 | secret_auth: 136 | bearer_digest: "$AUTH_DIGEST" 137 | profile: google 138 | client_id: "$GOOGLE_CLIENT_ID" 139 | client_secret: "$GOOGLE_CLIENT_SECRET" 140 | return_url: "$GOOGLE_AUTH_RETURN_URL" 141 | scopes: 142 | - "$GOOGLE_SCOPES" 143 | 144 | github_auth: 145 | secret_auth: 146 | bearer_digest: "$AUTH_DIGEST" 147 | profile: github 148 | client_id: "$GITHUB_CLIENT_ID" 149 | client_secret: "$GITHUB_CLIENT_SECRET" 150 | return_url: "$GITHUB_AUTH_RETURN_URL" 151 | scopes: 152 | - "$GITHUB_AUTH_SCOPES" 153 | 154 | # vanta: 155 | # secret_auth: 156 | # bearer: "$PROXY_AUTH" 157 | # profile: vanta 158 | # client_id: "$VANTA_CLIENT_ID" 159 | # client_secret: "$VANTA_CLIENT_SECRET" 160 | # return_url: "$VANTA_RETURN_URL" 161 | # scopes: 162 | # - "$VANTA_AUTH_SCOPES" 163 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | primary_region = "ord" 2 | kill_signal = "SIGTERM" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [experimental] 7 | auto_rollback = true 8 | 9 | [http_service] 10 | internal_port = 8080 11 | force_https = true 12 | auto_stop_machines = false 13 | auto_start_machines = false 14 | 15 | [http_service.concurrency] 16 | type = "requests" 17 | soft_limit = 10000 18 | hard_limit = 10000 19 | 20 | [[http_service.checks]] 21 | grace_period = "5s" 22 | interval = "30s" 23 | method = "GET" 24 | timeout = "1s" 25 | path = "/health" 26 | 27 | [env] 28 | PORT = "8080" -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/superfly/ssokenizer 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/alecthomas/assert/v2 v2.3.0 7 | github.com/sirupsen/logrus v1.9.3 8 | github.com/superfly/tokenizer v0.0.3-0.20250311175830-aae794513aa1 9 | github.com/vmihailenco/msgpack/v5 v5.3.5 10 | golang.org/x/crypto v0.12.0 11 | golang.org/x/oauth2 v0.10.0 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | cloud.google.com/go/compute v1.20.1 // indirect 17 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 18 | github.com/alecthomas/repr v0.2.0 // indirect 19 | github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect 20 | github.com/aws/smithy-go v1.20.3 // indirect 21 | github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect 22 | github.com/golang/protobuf v1.5.3 // indirect 23 | github.com/google/uuid v1.3.0 // indirect 24 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 25 | github.com/hexops/gotextdiff v1.0.3 // indirect 26 | github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648 // indirect 27 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 28 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect 29 | golang.org/x/net v0.12.0 // indirect 30 | golang.org/x/sys v0.11.0 // indirect 31 | google.golang.org/appengine v1.6.7 // indirect 32 | google.golang.org/protobuf v1.31.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg= 2 | cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= 3 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= 4 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= 5 | github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= 6 | github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= 7 | github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= 8 | github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 9 | github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= 10 | github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= 11 | github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE= 12 | github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= 17 | github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= 18 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= 19 | github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= 20 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 21 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 22 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 23 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 24 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 25 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 26 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 27 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 28 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 30 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 31 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 32 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 34 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 35 | github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= 36 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 37 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 41 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 42 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 43 | github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648 h1:YQG1v1QcTFQxJureNBcbtxosZ98u78ceUNCDQgI/vgM= 44 | github.com/superfly/macaroon v0.2.14-0.20240819201738-61a02aa53648/go.mod h1:Kt6/EdSYfFjR4GIe+erMwcJgU8iMu1noYVceQ5dNdKo= 45 | github.com/superfly/tokenizer v0.0.3-0.20250311175830-aae794513aa1 h1:BPqO/VvBOqBgjso0tl7Xj4He+ugf1fwrT4uHX3Oj0MU= 46 | github.com/superfly/tokenizer v0.0.3-0.20250311175830-aae794513aa1/go.mod h1:w38ieJ28pCyIpQJzuDOKfN5z6Q6R92vOkAYtUv6FL9k= 47 | github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= 48 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 49 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 50 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 51 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 52 | golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= 53 | golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 54 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= 55 | golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 56 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 57 | golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= 58 | golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= 59 | golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= 60 | golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 64 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 67 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 68 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 69 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 70 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 71 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 72 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 73 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 74 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /musickit/musickit.go: -------------------------------------------------------------------------------- 1 | package musickit 2 | 3 | import ( 4 | "crypto/subtle" 5 | "embed" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "net/http" 10 | 11 | "github.com/sirupsen/logrus" 12 | "github.com/superfly/ssokenizer" 13 | "github.com/superfly/tokenizer" 14 | ) 15 | 16 | var ( 17 | //go:embed templates/start.html 18 | startHTML string 19 | startTemplate = template.Must(template.New("start").Parse(startHTML)) 20 | 21 | //go:embed static/* 22 | statics embed.FS 23 | ) 24 | 25 | type Provider struct { 26 | ssokenizer.ProviderConfig 27 | DeveloperToken string 28 | } 29 | 30 | var _ ssokenizer.Provider = (*Provider)(nil) 31 | 32 | func (p *Provider) PC() *ssokenizer.ProviderConfig { 33 | return &p.ProviderConfig 34 | } 35 | 36 | func (p *Provider) Validate() error { 37 | switch err := p.ProviderConfig.Validate(); { 38 | case err != nil: 39 | return err 40 | case p.DeveloperToken == "": 41 | return errors.New("missing developer token") 42 | default: 43 | return nil 44 | } 45 | } 46 | 47 | func (p *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { 48 | m := http.NewServeMux() 49 | m.Handle("GET /static/", http.FileServer(http.FS(statics))) 50 | m.HandleFunc("GET /start", p.handleStart) 51 | m.HandleFunc("POST /callback", p.handleCallback) 52 | 53 | m.ServeHTTP(w, r) 54 | } 55 | 56 | func (p *Provider) handleStart(w http.ResponseWriter, r *http.Request) { 57 | tr := ssokenizer.StartTransaction(w, r) 58 | if tr == nil { 59 | return 60 | } 61 | 62 | td := struct { 63 | DeveloperToken string 64 | State string 65 | }{p.DeveloperToken, tr.Nonce} 66 | 67 | if err := startTemplate.Execute(w, &td); err != nil { 68 | getLog(r).WithError(err).Error("failed to execute start template") 69 | } 70 | } 71 | 72 | func (p *Provider) handleCallback(w http.ResponseWriter, r *http.Request) { 73 | tr := ssokenizer.RestoreTransaction(w, r) 74 | if tr == nil { 75 | return 76 | } 77 | 78 | state := r.FormValue("state") 79 | if state == "" { 80 | r = withError(r, errors.New("missing state")) 81 | tr.ReturnError(w, r, "missing state") 82 | return 83 | } 84 | 85 | if subtle.ConstantTimeCompare([]byte(tr.Nonce), []byte(state)) != 1 { 86 | r = withError(r, errors.New("bad state")) 87 | r = withFields(r, logrus.Fields{"have": state, "want": tr.Nonce}) 88 | tr.ReturnError(w, r, "bad response") 89 | return 90 | } 91 | 92 | token := r.FormValue("token") 93 | if token == "" { 94 | r = withError(r, errors.New("missing token")) 95 | tr.ReturnError(w, r, "missing token") 96 | return 97 | } 98 | 99 | sealed, err := p.Tokenizer.SealedSecret(&tokenizer.MultiProcessorConfig{ 100 | &tokenizer.InjectProcessorConfig{ 101 | Token: p.DeveloperToken, 102 | DstProcessor: tokenizer.DstProcessor{ 103 | Dst: "Authorization", 104 | }, 105 | FmtProcessor: tokenizer.FmtProcessor{ 106 | Fmt: "Bearer %s", 107 | }, 108 | }, 109 | &tokenizer.InjectProcessorConfig{ 110 | Token: token, 111 | DstProcessor: tokenizer.DstProcessor{ 112 | Dst: "Music-User-Token", 113 | }, 114 | FmtProcessor: tokenizer.FmtProcessor{ 115 | Fmt: "%s", 116 | }, 117 | }, 118 | }) 119 | if err != nil { 120 | r = withError(r, fmt.Errorf("failed seal: %w", err)) 121 | tr.ReturnError(w, r, "seal error") 122 | return 123 | } 124 | 125 | tr.ReturnData(w, r, map[string]string{"sealed": sealed}) 126 | } 127 | 128 | // logging helpers. aliased for convenience 129 | var ( 130 | getLog = ssokenizer.GetLog 131 | withError = ssokenizer.WithError 132 | withFields = ssokenizer.WithFields 133 | ) 134 | -------------------------------------------------------------------------------- /musickit/templates/start.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Authenticating to MusicKit 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 |
17 | 18 | 19 |
20 | 21 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /oauth2/oauth2.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "crypto/subtle" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/sirupsen/logrus" 15 | "github.com/superfly/ssokenizer" 16 | "github.com/superfly/tokenizer" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | type Provider struct { 21 | ssokenizer.ProviderConfig 22 | OAuthConfig oauth2.Config 23 | 24 | // ForwardParams are the parameters that should be forwarded from the start 25 | // request to the auth URL. 26 | ForwardParams []string 27 | } 28 | 29 | var _ ssokenizer.Provider = (*Provider)(nil) 30 | 31 | const ( 32 | startPath = "/start" 33 | callbackPath = "/callback" 34 | refreshPath = "/refresh" 35 | ) 36 | 37 | // PC implements the ssokenizer.Provider interface. 38 | func (p *Provider) PC() *ssokenizer.ProviderConfig { 39 | return &p.ProviderConfig 40 | } 41 | 42 | // Validate implements the ssokenizer.Provider interface. 43 | func (p *Provider) Validate() error { 44 | switch err := p.ProviderConfig.Validate(); { 45 | case err != nil: 46 | return err 47 | case p.OAuthConfig.ClientID == "": 48 | return errors.New("missing client_id") 49 | case p.OAuthConfig.ClientSecret == "": 50 | return errors.New("missing client_secret") 51 | case p.OAuthConfig.Endpoint.AuthURL == "": 52 | return errors.New("missing auth_url") 53 | case p.OAuthConfig.Endpoint.TokenURL == "": 54 | return errors.New("missing token_url") 55 | default: 56 | return nil 57 | } 58 | } 59 | 60 | func (p *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { 61 | switch path := strings.TrimSuffix(r.URL.Path, "/"); path { 62 | case startPath: 63 | p.handleStart(w, r) 64 | case callbackPath: 65 | p.handleCallback(w, r) 66 | case refreshPath: 67 | p.handleRefresh(w, r) 68 | default: 69 | w.WriteHeader(http.StatusNotFound) 70 | } 71 | } 72 | 73 | func (p *Provider) handleStart(w http.ResponseWriter, r *http.Request) { 74 | defer getLog(r).WithField("status", http.StatusFound).Info() 75 | 76 | tr := ssokenizer.StartTransaction(w, r) 77 | if tr == nil { 78 | return 79 | } 80 | 81 | opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} 82 | 83 | for _, param := range p.ForwardParams { 84 | if value := r.URL.Query().Get(param); value != "" { 85 | opts = append(opts, oauth2.SetAuthURLParam(param, value)) 86 | } 87 | } 88 | 89 | if p.OAuthConfig.RedirectURL == "" { 90 | opts = append(opts, oauth2.SetAuthURLParam("redirect_uri", p.URL.JoinPath(callbackPath).String())) 91 | } 92 | 93 | url := p.OAuthConfig.AuthCodeURL(tr.Nonce, opts...) 94 | http.Redirect(w, r, url, http.StatusFound) 95 | } 96 | 97 | func (p *Provider) handleCallback(w http.ResponseWriter, r *http.Request) { 98 | tr := ssokenizer.RestoreTransaction(w, r) 99 | if tr == nil { 100 | return 101 | } 102 | params := r.URL.Query() 103 | 104 | if errParam := params.Get("error"); errParam != "" { 105 | r = withError(r, fmt.Errorf("error param: %s", errParam)) 106 | tr.ReturnError(w, r, errParam) 107 | return 108 | } 109 | 110 | state := params.Get("state") 111 | if state == "" { 112 | r = withError(r, errors.New("missing state")) 113 | tr.ReturnError(w, r, "bad response") 114 | return 115 | } 116 | 117 | if subtle.ConstantTimeCompare([]byte(tr.Nonce), []byte(state)) != 1 { 118 | r = withError(r, errors.New("bad state")) 119 | r = withFields(r, logrus.Fields{"have": state, "want": tr.Nonce}) 120 | tr.ReturnError(w, r, "bad response") 121 | return 122 | } 123 | 124 | code := params.Get("code") 125 | if code == "" { 126 | r = withError(r, errors.New("missing code")) 127 | tr.ReturnError(w, r, "bad response") 128 | return 129 | } 130 | 131 | opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} 132 | if p.OAuthConfig.RedirectURL == "" { 133 | opts = append(opts, oauth2.SetAuthURLParam("redirect_uri", p.URL.JoinPath(callbackPath).String())) 134 | } 135 | 136 | tok, err := p.OAuthConfig.Exchange(r.Context(), code, opts...) 137 | if err != nil { 138 | r = withError(r, fmt.Errorf("failed exchange: %w", err)) 139 | tr.ReturnError(w, r, "bad response") 140 | return 141 | } 142 | 143 | r = withIdToken(r, tok) 144 | 145 | if t := tok.Type(); t != "Bearer" { 146 | r = withField(r, "type", t) 147 | r = withError(r, errors.New("unrecognized token type")) 148 | tr.ReturnError(w, r, "bad response") 149 | return 150 | } 151 | 152 | sealed, err := p.Tokenizer.SealedSecret(&tokenizer.OAuthProcessorConfig{ 153 | Token: &tokenizer.OAuthToken{ 154 | AccessToken: tok.AccessToken, 155 | RefreshToken: tok.RefreshToken}, 156 | }) 157 | if err != nil { 158 | r = withError(r, fmt.Errorf("failed seal: %w", err)) 159 | tr.ReturnError(w, r, "seal error") 160 | return 161 | } 162 | 163 | tr.ReturnData(w, r, map[string]string{ 164 | "sealed": sealed, 165 | "expires": strconv.FormatInt(tok.Expiry.Unix(), 10), 166 | }) 167 | } 168 | 169 | func (p *Provider) handleRefresh(w http.ResponseWriter, r *http.Request) { 170 | refreshToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") 171 | if !ok { 172 | getLog(r). 173 | WithField("status", http.StatusUnauthorized). 174 | Info("refresh: missing token") 175 | 176 | w.WriteHeader(http.StatusUnauthorized) 177 | return 178 | } 179 | 180 | tok, err := p.OAuthConfig.TokenSource(r.Context(), &oauth2.Token{RefreshToken: refreshToken}).Token() 181 | if err != nil { 182 | getLog(r). 183 | WithField("status", http.StatusBadGateway). 184 | WithError(err). 185 | Info("refresh") 186 | 187 | w.WriteHeader(http.StatusBadGateway) 188 | return 189 | } 190 | 191 | r = withIdToken(r, tok) 192 | 193 | if t := tok.Type(); t != "Bearer" { 194 | getLog(r). 195 | WithField("status", http.StatusInternalServerError). 196 | WithField("type", t). 197 | Info("unrecognized token type") 198 | 199 | w.WriteHeader(http.StatusInternalServerError) 200 | return 201 | } 202 | 203 | sealed, err := p.Tokenizer.SealedSecret(&tokenizer.OAuthProcessorConfig{ 204 | Token: &tokenizer.OAuthToken{ 205 | AccessToken: tok.AccessToken, 206 | RefreshToken: tok.RefreshToken, 207 | }, 208 | }) 209 | if err != nil { 210 | getLog(r). 211 | WithField("status", http.StatusInternalServerError). 212 | WithError(err). 213 | Info("refresh: failed seal") 214 | 215 | w.WriteHeader(http.StatusInternalServerError) 216 | return 217 | } 218 | 219 | w.Header().Set("Content-Type", "text/plain") 220 | w.Header().Set("Cache-Control", fmt.Sprintf("private, max-age=%d", time.Until(tok.Expiry)/time.Second)) 221 | 222 | if _, err := w.Write([]byte(sealed)); err != nil { 223 | // status already written 224 | getLog(r). 225 | WithError(err). 226 | Info("refresh: write response") 227 | 228 | return 229 | } 230 | 231 | getLog(r). 232 | WithField("status", http.StatusOK). 233 | Info() 234 | } 235 | 236 | // logging helpers. aliased for convenience 237 | var ( 238 | getLog = ssokenizer.GetLog 239 | withError = ssokenizer.WithError 240 | withField = ssokenizer.WithField 241 | withFields = ssokenizer.WithFields 242 | ) 243 | 244 | // logging helper. Tries to find and parse user info from id token. 245 | func withIdToken(r *http.Request, tok *oauth2.Token) *http.Request { 246 | idToken, ok := tok.Extra("id_token").(string) 247 | if !ok { 248 | return r 249 | } 250 | 251 | parts := strings.Split(idToken, ".") 252 | if len(parts) < 2 { 253 | return r 254 | } 255 | 256 | jbody, err := base64.RawURLEncoding.DecodeString(parts[1]) 257 | if err != nil { 258 | return r 259 | } 260 | 261 | var body struct { 262 | Sub string `json:"sub"` 263 | HD string `json:"hd"` 264 | Email string `json:"email"` 265 | } 266 | if err := json.Unmarshal(jbody, &body); err != nil { 267 | return r 268 | } 269 | 270 | if body.Sub != "" { 271 | r = withField(r, "sub", body.Sub) 272 | } 273 | if body.HD != "" { 274 | r = withField(r, "hd", body.HD) 275 | } 276 | if body.Email != "" { 277 | r = withField(r, "email", body.Email) 278 | } 279 | 280 | return r 281 | } 282 | -------------------------------------------------------------------------------- /oauth2/oauth2_test.go: -------------------------------------------------------------------------------- 1 | package oauth2 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "io" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "net/http/httptest" 11 | "net/url" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/alecthomas/assert/v2" 18 | "github.com/sirupsen/logrus" 19 | "github.com/superfly/ssokenizer" 20 | "github.com/superfly/tokenizer" 21 | "golang.org/x/crypto/nacl/box" 22 | "golang.org/x/oauth2" 23 | ) 24 | 25 | func init() { 26 | logrus.SetLevel(logrus.DebugLevel) 27 | } 28 | 29 | const rpAuth = "555" 30 | 31 | func setupTestServers(t *testing.T) (*httptest.Server, *ssokenizer.Server, *httptest.Server, *httptest.Server) { 32 | rpServer := httptest.NewServer(rp) 33 | t.Cleanup(rpServer.Close) 34 | returnURL, err := url.Parse(rpServer.URL) 35 | assert.NoError(t, err) 36 | t.Logf("rp=%s", rpServer.URL) 37 | 38 | idpServer := httptest.NewServer(idp) 39 | t.Cleanup(idpServer.Close) 40 | t.Logf("idp=%s", idpServer.URL) 41 | 42 | var ( 43 | pub, priv, _ = box.GenerateKey(rand.Reader) 44 | sealKey = hex.EncodeToString(pub[:]) 45 | openKey = hex.EncodeToString(priv[:]) 46 | ) 47 | 48 | tkz := tokenizer.NewTokenizer(openKey) 49 | tkz.Tr = http.DefaultTransport.(*http.Transport) // disable TLS requirement for app server 50 | tkzServer := httptest.NewServer(tkz) 51 | t.Cleanup(tkzServer.Close) 52 | 53 | providers := make(ssokenizer.StaticProviderRegistry) 54 | skz := ssokenizer.NewServer(providers) 55 | assert.NoError(t, skz.Start("127.0.0.1:")) 56 | t.Logf("skz=http://%s", skz.Address) 57 | t.Cleanup(func() { 58 | assert.NoError(t, skz.Shutdown(context.Background())) 59 | <-skz.Done 60 | assert.NoError(t, skz.Err) 61 | }) 62 | 63 | skzURL, err := url.Parse("http://" + skz.Address) 64 | assert.NoError(t, err) 65 | providerURL := skzURL.JoinPath("/idp") 66 | 67 | // we don't know our URL in tests until the server is started, so we can't 68 | // populate this earlier. 69 | providers["idp"] = &Provider{ 70 | ProviderConfig: ssokenizer.ProviderConfig{ 71 | Tokenizer: ssokenizer.TokenizerConfig{ 72 | SealKey: sealKey, 73 | Auth: tokenizer.NewBearerAuthConfig(rpAuth), 74 | }, 75 | URL: *providerURL, 76 | ReturnURL: *returnURL, 77 | }, 78 | OAuthConfig: oauth2.Config{ 79 | ClientID: testClientID, 80 | ClientSecret: testClientSecret, 81 | Endpoint: oauth2.Endpoint{ 82 | AuthURL: idpServer.URL + "/auth", 83 | TokenURL: idpServer.URL + "/token", 84 | }, 85 | Scopes: []string{"my scope"}, 86 | }, 87 | } 88 | 89 | return rpServer, skz, tkzServer, idpServer 90 | } 91 | 92 | func checkResponse(t *testing.T, resp *http.Response, expectedPrefix, expectedState string) string { 93 | assert.Equal(t, http.StatusOK, resp.StatusCode) 94 | assert.True(t, strings.HasPrefix(resp.Request.URL.String(), expectedPrefix)) 95 | state := resp.Request.URL.Query().Get("state") 96 | assert.Equal(t, expectedState, state) 97 | errMsg := resp.Request.URL.Query().Get("error") 98 | assert.Equal(t, "", errMsg) 99 | sealed := resp.Request.URL.Query().Get("sealed") 100 | assert.NotEqual(t, "", sealed) 101 | sexpires := resp.Request.URL.Query().Get("expires") 102 | iexpires, err := strconv.ParseInt(sexpires, 10, 64) 103 | assert.NoError(t, err) 104 | expires := time.Unix(iexpires, 0) 105 | assert.Equal(t, 3599, time.Until(expires)/time.Second) 106 | return sealed 107 | } 108 | 109 | func TestOauth2(t *testing.T) { 110 | rpServer, skz, tkzServer, idpServer := setupTestServers(t) 111 | 112 | client := new(http.Client) 113 | client.Jar, _ = cookiejar.New(nil) 114 | client.Jar = noSecureJar{client.Jar} 115 | 116 | resp, err := client.Get("http://" + skz.Address + "/idp/start") 117 | assert.NoError(t, err) 118 | sealed := checkResponse(t, resp, rpServer.URL, "") 119 | 120 | tkzClient, err := tokenizer.Client(tkzServer.URL, tokenizer.WithAuth(rpAuth), tokenizer.WithSecret(sealed, nil)) 121 | assert.NoError(t, err) 122 | resp, err = tkzClient.Get(idpServer.URL + "/api") 123 | assert.NoError(t, err) 124 | assert.Equal(t, http.StatusOK, resp.StatusCode) 125 | 126 | withRefresh := map[string]string{tokenizer.ParamSubtoken: tokenizer.SubtokenRefresh} 127 | refreshClient, err := tokenizer.Client(tkzServer.URL, tokenizer.WithAuth(rpAuth), tokenizer.WithSecret(sealed, withRefresh)) 128 | assert.NoError(t, err) 129 | resp, err = refreshClient.Get("http://" + skz.Address + "/idp/refresh") 130 | assert.NoError(t, err) 131 | assert.Equal(t, http.StatusOK, resp.StatusCode) 132 | bldr := new(strings.Builder) 133 | _, err = io.Copy(bldr, resp.Body) 134 | assert.NoError(t, err) 135 | assert.Equal(t, "private, max-age=3599", resp.Header.Get("Cache-Control")) 136 | 137 | sealed = bldr.String() 138 | tkzClient, err = tokenizer.Client(tkzServer.URL, tokenizer.WithAuth(rpAuth), tokenizer.WithSecret(sealed, nil)) 139 | assert.NoError(t, err) 140 | resp, err = tkzClient.Get(idpServer.URL + "/api") 141 | assert.NoError(t, err) 142 | assert.Equal(t, http.StatusOK, resp.StatusCode) 143 | } 144 | 145 | // tests that when two parallel flows are initiated, they do not interfere and the second can 146 | // complete successfully. 147 | func TestOauth2Parallel(t *testing.T) { 148 | rpServer, skz, _, idpServer := setupTestServers(t) 149 | 150 | sharedJar, _ := cookiejar.New(nil) 151 | 152 | clientA := new(http.Client) 153 | clientA.CheckRedirect = func(req *http.Request, via []*http.Request) error { 154 | if strings.HasPrefix(req.URL.String(), idpServer.URL) { 155 | return nil // follow redirect to idp 156 | } 157 | return http.ErrUseLastResponse // don't follow redirect back from idp, simulating abandoned flow. 158 | } 159 | clientA.Jar = noSecureJar{sharedJar} 160 | _, err := clientA.Get("http://" + skz.Address + "/idp/start?state=first") 161 | assert.NoError(t, err) 162 | 163 | clientB := new(http.Client) 164 | clientB.Jar = noSecureJar{sharedJar} 165 | 166 | resp, err := clientB.Get("http://" + skz.Address + "/idp/start?state=second") 167 | assert.NoError(t, err) 168 | checkResponse(t, resp, rpServer.URL, "second") 169 | } 170 | 171 | const ( 172 | testClientID = "my-client-id" 173 | testClientSecret = "my-client-secret" 174 | ) 175 | 176 | var idp = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 177 | if err := r.ParseForm(); err != nil { 178 | w.WriteHeader(http.StatusBadRequest) 179 | return 180 | } 181 | 182 | username, password, _ := r.BasicAuth() 183 | authorization := r.Header.Get("Authorization") 184 | 185 | logrus.WithFields(logrus.Fields{ 186 | "server": "idp", 187 | "method": r.Method, 188 | "url": r.URL.String(), 189 | "form": r.Form, 190 | "username": username, 191 | "password": password, 192 | "authorization": authorization, 193 | }).Info() 194 | 195 | switch r.URL.Path { 196 | case "/auth": 197 | if r.Method != http.MethodGet { 198 | w.WriteHeader(http.StatusBadRequest) 199 | return 200 | } 201 | 202 | query := r.URL.Query() 203 | 204 | switch query.Get("client_id") { 205 | case testClientID: 206 | default: 207 | w.WriteHeader(http.StatusBadRequest) 208 | return 209 | } 210 | 211 | ru := query.Get("redirect_uri") 212 | if ru == "" { 213 | w.WriteHeader(http.StatusBadRequest) 214 | return 215 | } 216 | ruu, err := url.Parse(ru) 217 | if err != nil { 218 | w.WriteHeader(http.StatusBadRequest) 219 | return 220 | } 221 | params := make(url.Values) 222 | params.Set("code", "111") 223 | params.Set("state", query.Get("state")) 224 | ruu.RawQuery = params.Encode() 225 | 226 | http.Redirect(w, r, ruu.String(), http.StatusFound) 227 | return 228 | case "/token": 229 | if r.Method != http.MethodPost { 230 | w.WriteHeader(http.StatusBadRequest) 231 | return 232 | } 233 | 234 | if username != testClientID || password != testClientSecret { 235 | w.WriteHeader(http.StatusUnauthorized) 236 | return 237 | } 238 | 239 | switch { 240 | case r.Form.Get("code") == "111": 241 | case r.Form.Get("refresh_token") == "888": 242 | default: 243 | w.WriteHeader(http.StatusUnauthorized) 244 | return 245 | } 246 | 247 | w.Header().Set("Content-Type", "application/json") 248 | w.Write([]byte(`{"access_token": "999", "token_type": "Bearer", "refresh_token": "888", "expires_in": 3600}`)) 249 | return 250 | case "/api": 251 | if authorization != "Bearer 999" { 252 | w.WriteHeader(http.StatusUnauthorized) 253 | } 254 | return 255 | } 256 | }) 257 | 258 | var rp = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 259 | if err := r.ParseForm(); err != nil { 260 | w.WriteHeader(http.StatusBadRequest) 261 | return 262 | } 263 | 264 | logrus.WithFields(logrus.Fields{ 265 | "server": "rp", 266 | "method": r.Method, 267 | "url": r.URL.String(), 268 | "form": r.Form, 269 | }).Info() 270 | }) 271 | 272 | type noSecureJar struct { 273 | http.CookieJar 274 | } 275 | 276 | func (j noSecureJar) SetCookies(u *url.URL, cookies []*http.Cookie) { 277 | for _, cookie := range cookies { 278 | cookie.Secure = false 279 | } 280 | j.CookieJar.SetCookies(u, cookies) 281 | } 282 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | package ssokenizer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/superfly/tokenizer" 10 | ) 11 | 12 | type ProviderRegistry interface { 13 | Get(ctx context.Context, name string) (Provider, error) 14 | } 15 | 16 | type StaticProviderRegistry map[string]Provider 17 | 18 | var ErrProviderNotFound = errors.New("provider not found") 19 | 20 | func (r StaticProviderRegistry) Get(ctx context.Context, name string) (Provider, error) { 21 | if p, ok := r[name]; ok { 22 | return p, nil 23 | } 24 | 25 | return nil, ErrProviderNotFound 26 | } 27 | 28 | type Provider interface { 29 | http.Handler 30 | Validate() error 31 | PC() *ProviderConfig 32 | } 33 | 34 | type ProviderConfig struct { 35 | Tokenizer TokenizerConfig 36 | 37 | // URL is the full URL where this provider is served from. 38 | URL url.URL 39 | 40 | // ReturnURL is the URL that the provider should redirect to after 41 | // authenticating the user. 42 | ReturnURL url.URL 43 | } 44 | 45 | func (p *ProviderConfig) Validate() error { 46 | switch err := p.Tokenizer.Validate(); { 47 | case err != nil: 48 | return err 49 | case !isFullURL(&p.URL): 50 | return errors.New("missing provider URL") 51 | case !isFullURL(&p.ReturnURL): 52 | return errors.New("missing return URL") 53 | default: 54 | return nil 55 | } 56 | } 57 | 58 | func isFullURL(u *url.URL) bool { 59 | return u.Scheme != "" && u.Host != "" 60 | } 61 | 62 | type TokenizerConfig struct { 63 | // SealKey is the key we encrypt tokens to. 64 | SealKey string 65 | 66 | // Auth specifies the auth requires to use the sealed token. 67 | Auth tokenizer.AuthConfig 68 | 69 | // RequestValidators specifies validations that tokenizer should run on 70 | // requests before unsealing/adding token. Eg. limit what hosts the token 71 | // can be sent to. 72 | RequestValidators []tokenizer.RequestValidator 73 | } 74 | 75 | func (t *TokenizerConfig) SealedSecret(processor tokenizer.ProcessorConfig) (string, error) { 76 | secret := tokenizer.Secret{ 77 | AuthConfig: t.Auth, 78 | ProcessorConfig: processor, 79 | RequestValidators: t.RequestValidators, 80 | } 81 | 82 | return secret.Seal(t.SealKey) 83 | } 84 | 85 | func (t *TokenizerConfig) Validate() error { 86 | switch { 87 | case t.SealKey == "": 88 | return errors.New("missing tokenizer seal key") 89 | case t.Auth == nil: 90 | return errors.New("missing tokenizer auth config") 91 | default: 92 | return nil 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /ssokenizer.go: -------------------------------------------------------------------------------- 1 | package ssokenizer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type Server struct { 15 | // Address is populated with the listening address after [Start] is called. 16 | Address string 17 | 18 | // Done is closed when the server has stopped. It is not populated until 19 | // [Start] is called. 20 | Done chan struct{} 21 | 22 | // Err is populated with any error returned by the HTTP server. It should 23 | // not be read until Done is closed. 24 | Err error 25 | 26 | providers ProviderRegistry 27 | http *http.Server 28 | } 29 | 30 | // Returns a new Server. 31 | func NewServer(providers ProviderRegistry) *Server { 32 | s := &Server{providers: providers} 33 | s.http = &http.Server{Handler: s} 34 | 35 | return s 36 | } 37 | 38 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 39 | parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") 40 | if parts[0] == "health" { 41 | fmt.Fprintln(w, "ok") 42 | return 43 | } 44 | 45 | providerName := strings.Join(parts[0:len(parts)-1], "/") 46 | 47 | providerName, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") 48 | if providerName == "health" { 49 | fmt.Fprintln(w, "ok") 50 | return 51 | } 52 | 53 | r = WithFields(r, logrus.Fields{"method": r.Method, "uri": r.URL.Path, "host": r.Host}) 54 | 55 | provider, err := s.providers.Get(r.Context(), providerName) 56 | switch { 57 | case errors.Is(err, ErrProviderNotFound): 58 | GetLog(r).WithField("status", http.StatusNotFound).Info() 59 | w.WriteHeader(http.StatusNotFound) 60 | return 61 | case err != nil: 62 | GetLog(r).WithError(err).WithField("status", http.StatusInternalServerError).Info() 63 | w.WriteHeader(http.StatusInternalServerError) 64 | return 65 | } 66 | 67 | if err = provider.Validate(); err != nil { 68 | GetLog(r).WithError(err).WithField("status", http.StatusInternalServerError).Warn() 69 | w.WriteHeader(http.StatusInternalServerError) 70 | return 71 | } 72 | 73 | r = WithProvider(r, provider) 74 | r.URL.Path = "/" + rest 75 | provider.ServeHTTP(w, r) 76 | } 77 | 78 | // Start the server in a goroutine, listening at the specified address 79 | // (host:port). 80 | func (s *Server) Start(address string) error { 81 | l, err := net.Listen("tcp", address) 82 | if err != nil { 83 | return err 84 | } 85 | s.Address = l.Addr().String() 86 | 87 | s.Done = make(chan struct{}) 88 | go func() { 89 | defer close(s.Done) 90 | if err := s.http.Serve(l); err != http.ErrServerClosed { 91 | s.Err = err 92 | } 93 | }() 94 | 95 | return nil 96 | } 97 | 98 | // Gracefully shut down the server. If the context is cancelled before the 99 | // shutdown completes, the server will be shutdown immediately. 100 | func (s *Server) Shutdown(ctx context.Context) error { 101 | return s.http.Shutdown(ctx) 102 | } 103 | -------------------------------------------------------------------------------- /transaction.go: -------------------------------------------------------------------------------- 1 | package ssokenizer 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "net/http" 10 | "strings" 11 | "time" 12 | 13 | "github.com/vmihailenco/msgpack/v5" 14 | ) 15 | 16 | const ( 17 | transactionCookieName = "transaction" 18 | transactionTTL = time.Hour 19 | ) 20 | 21 | // State about the user's SSO attempt that is stored as a cookie. Cookies are 22 | // set with per-provider paths to prevent transactions from different providers 23 | // from interfering with each other. 24 | type Transaction struct { 25 | // Random state string that will be returned in our redirect to the relying 26 | // party. This is used to prevent login-CSRF attacks. 27 | ReturnState string 28 | 29 | // Random string that provider implementations can use as the state 30 | // parameter for downstream SSO flows. 31 | Nonce string 32 | 33 | // Time after which this transaction cookie will be ignored. 34 | Expiry time.Time 35 | } 36 | 37 | // Return the user to the returnURL with the provided data set as query string 38 | // parameters. 39 | func (t *Transaction) ReturnData(w http.ResponseWriter, r *http.Request, data map[string]string) { 40 | t.returnData(w, r, data) 41 | } 42 | 43 | // Return the user to the returnURL with the provided msg set in the `error` 44 | // query string parameter. 45 | func (t *Transaction) ReturnError(w http.ResponseWriter, r *http.Request, msg string) { 46 | t.returnData(w, r, map[string]string{"error": msg}) 47 | } 48 | 49 | func (t *Transaction) returnData(w http.ResponseWriter, r *http.Request, data map[string]string) { 50 | defer GetLog(r).WithField("status", http.StatusFound).Info() 51 | 52 | t.setCookie(w, r, "") 53 | 54 | // important that this is a copy! 55 | returnURL := GetProvider(r).PC().ReturnURL 56 | q := returnURL.Query() 57 | 58 | for k, v := range data { 59 | q.Set(k, v) 60 | } 61 | 62 | if t.ReturnState != "" { 63 | q.Set("state", t.ReturnState) 64 | } 65 | 66 | returnURL.RawQuery = q.Encode() 67 | http.Redirect(w, r, returnURL.String(), http.StatusFound) 68 | } 69 | 70 | func StartTransaction(w http.ResponseWriter, r *http.Request) *Transaction { 71 | t := &Transaction{ 72 | ReturnState: r.URL.Query().Get("state"), 73 | Nonce: randHex(16), 74 | Expiry: time.Now().Add(transactionTTL), 75 | } 76 | 77 | ts, err := t.marshal() 78 | if err != nil { 79 | r = WithError(r, fmt.Errorf("marshal transaction cookie: %w", err)) 80 | t.ReturnError(w, r, "unexpected error") 81 | return nil 82 | } 83 | 84 | t.setCookie(w, r, ts) 85 | return t 86 | } 87 | 88 | func RestoreTransaction(w http.ResponseWriter, r *http.Request) *Transaction { 89 | var t Transaction 90 | 91 | tc, err := r.Cookie(transactionCookieName) 92 | if err != nil || tc.Value == "" { 93 | r = WithError(r, fmt.Errorf("missing transaction cookie: %w", err)) 94 | t.ReturnError(w, r, "bad request") 95 | return nil 96 | } 97 | 98 | if err := unmarshalTransaction(&t, tc.Value); err != nil { 99 | r = WithError(r, fmt.Errorf("bad transaction cookie: %w", err)) 100 | t.ReturnError(w, r, "bad request") 101 | return nil 102 | } 103 | 104 | if time.Now().After(t.Expiry) { 105 | r = WithError(r, errors.New("expired transaction")) 106 | t.ReturnError(w, r, "expired") 107 | return nil 108 | } 109 | 110 | return &t 111 | } 112 | 113 | func unmarshalTransaction(t *Transaction, s string) error { 114 | m, err := base64.StdEncoding.DecodeString(s) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | return msgpack.Unmarshal(m, t) 120 | } 121 | 122 | func (t *Transaction) marshal() (string, error) { 123 | m, err := msgpack.Marshal(t) 124 | if err != nil { 125 | return "", err 126 | } 127 | 128 | return base64.StdEncoding.EncodeToString(m), nil 129 | } 130 | 131 | func (t *Transaction) setCookie(w http.ResponseWriter, r *http.Request, v string) { 132 | providerURL := &GetProvider(r).PC().URL 133 | 134 | var maxAge int 135 | if v == "" { 136 | maxAge = -1 137 | } 138 | 139 | http.SetCookie(w, &http.Cookie{ 140 | Name: transactionCookieName, 141 | Value: v, 142 | Path: providerURL.Path, 143 | Secure: strings.EqualFold(providerURL.Scheme, "https"), 144 | HttpOnly: true, 145 | SameSite: http.SameSiteLaxMode, 146 | MaxAge: maxAge, 147 | }) 148 | } 149 | 150 | func randHex(n int) string { 151 | b := make([]byte, n) 152 | rand.Read(b) 153 | return hex.EncodeToString(b) 154 | } 155 | -------------------------------------------------------------------------------- /vanta/vanta.go: -------------------------------------------------------------------------------- 1 | package vanta 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/superfly/ssokenizer" 11 | "github.com/superfly/ssokenizer/oauth2" 12 | xoauth2 "golang.org/x/oauth2" 13 | ) 14 | 15 | const ( 16 | invalidatePath = "/invalidate" 17 | invalidateURL = "https://api.vanta.com/v1/oauth/token/suspend" 18 | ) 19 | 20 | type Provider struct { 21 | oauth2.Provider 22 | } 23 | 24 | func (p *Provider) ServeHTTP(w http.ResponseWriter, r *http.Request) { 25 | if strings.TrimSuffix(r.URL.Path, "/") != invalidatePath { 26 | p.Provider.ServeHTTP(w, r) 27 | return 28 | } 29 | 30 | var ( 31 | ctx = r.Context() 32 | log = ssokenizer.GetLog(r) 33 | ) 34 | 35 | accessToken, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") 36 | if !ok { 37 | log.WithField("status", http.StatusUnauthorized). 38 | Info("invalidate: missing token") 39 | 40 | w.WriteHeader(http.StatusUnauthorized) 41 | return 42 | } 43 | 44 | tok, err := p.OAuthConfig.TokenSource(ctx, &xoauth2.Token{AccessToken: accessToken}).Token() 45 | if err != nil { 46 | log.WithField("status", http.StatusForbidden). 47 | WithError(err). 48 | Info("invalidate: failed to get token") 49 | 50 | w.WriteHeader(http.StatusForbidden) 51 | return 52 | } 53 | 54 | if typ := tok.Type(); typ != "Bearer" { 55 | log.WithField("status", http.StatusForbidden). 56 | WithField("type", typ). 57 | WithError(err). 58 | Info("invalidate: bad token type") 59 | 60 | w.WriteHeader(http.StatusForbidden) 61 | return 62 | } 63 | 64 | body, err := json.Marshal(map[string]string{ 65 | "token": tok.AccessToken, 66 | "client_id": p.OAuthConfig.ClientID, 67 | "client_secret": p.OAuthConfig.ClientSecret, 68 | }) 69 | if err != nil { 70 | log.WithField("status", http.StatusInternalServerError). 71 | WithError(err). 72 | Info("invalidate: marshal json") 73 | 74 | w.WriteHeader(http.StatusInternalServerError) 75 | return 76 | } 77 | 78 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, invalidateURL, bytes.NewBuffer(body)) 79 | if err != nil { 80 | log.WithField("status", http.StatusInternalServerError). 81 | WithError(err). 82 | Info("invalidate: make request") 83 | 84 | w.WriteHeader(http.StatusInternalServerError) 85 | return 86 | } 87 | 88 | req.Header.Set("Content-Type", "application/json") 89 | client := http.Client{Timeout: 10 * time.Second} 90 | 91 | resp, err := client.Do(req) 92 | if err != nil { 93 | log.WithField("status", http.StatusServiceUnavailable). 94 | WithError(err). 95 | Info("invalidate: send request") 96 | 97 | w.WriteHeader(http.StatusServiceUnavailable) 98 | return 99 | } 100 | 101 | log.WithField("status", resp.Status). 102 | Info("invalidate: success") 103 | } 104 | --------------------------------------------------------------------------------