diff options
author | Mike Crute <mike@crute.us> | 2021-11-16 14:46:24 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2021-11-17 07:56:10 -0800 |
commit | cc58a3da7d647de8520e33dc4356672d2ed1a366 (patch) | |
tree | 1b232a0d51446eb6370cfb13932190d31ce053df /auth | |
parent | a42d794a286154a3106551e6e483861af2a9ef16 (diff) | |
download | cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.tar.bz2 cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.tar.xz cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.zip |
Import of source code
Diffstat (limited to 'auth')
-rw-r--r-- | auth/github/github.go | 127 | ||||
-rw-r--r-- | auth/jwt.go | 123 |
2 files changed, 250 insertions, 0 deletions
diff --git a/auth/github/github.go b/auth/github/github.go new file mode 100644 index 0000000..b403296 --- /dev/null +++ b/auth/github/github.go | |||
@@ -0,0 +1,127 @@ | |||
1 | package github | ||
2 | |||
3 | import ( | ||
4 | "crypto/rand" | ||
5 | "encoding/base64" | ||
6 | "encoding/json" | ||
7 | "fmt" | ||
8 | "io/ioutil" | ||
9 | "net/http" | ||
10 | "net/url" | ||
11 | "strings" | ||
12 | ) | ||
13 | |||
14 | type apiLoginResponse struct { | ||
15 | Username string `json:"login"` | ||
16 | } | ||
17 | |||
18 | type GitHubToken struct { | ||
19 | AccessToken string `json:"access_token"` | ||
20 | RefreshToken string `json:"refresh_token"` | ||
21 | } | ||
22 | |||
23 | type GitHubAuthenticator struct { | ||
24 | ClientId string | ||
25 | ClientSecret string | ||
26 | } | ||
27 | |||
28 | // GetAuthRedirect returns a redirect URL and a state parameter that should be | ||
29 | // stored in the user's browser to correlate the authentication result and | ||
30 | // request. | ||
31 | func (a *GitHubAuthenticator) GetAuthRedirect() (string, string) { | ||
32 | sr := make([]byte, 32) | ||
33 | if _, err := rand.Read(sr); err != nil { | ||
34 | panic(err) // This should only happen if no entropy available | ||
35 | } | ||
36 | random := base64.URLEncoding.EncodeToString(sr) | ||
37 | |||
38 | return (&url.URL{ | ||
39 | Scheme: "https", | ||
40 | Host: "github.com", | ||
41 | Path: "/login/oauth/authorize", | ||
42 | RawQuery: url.Values{ | ||
43 | "client_id": []string{a.ClientId}, | ||
44 | "scope": []string{"read:user"}, | ||
45 | "state": []string{random}, | ||
46 | "allow_signup": []string{"false"}, | ||
47 | }.Encode(), | ||
48 | }).String(), random | ||
49 | } | ||
50 | |||
51 | // GetTokens returns the GitHub Oauth tokens from the service given a token | ||
52 | // request code. The returned tokens are used for authentication to the GitHub | ||
53 | // API. | ||
54 | // | ||
55 | // This relies on a GitHub Oauth application on the GitHub side to act as the | ||
56 | // client for these tokens. | ||
57 | func (a *GitHubAuthenticator) GetTokens(code string) (*GitHubToken, error) { | ||
58 | r, err := (&http.Client{}).Do(&http.Request{ | ||
59 | Method: http.MethodPost, | ||
60 | URL: &url.URL{ | ||
61 | Scheme: "https", | ||
62 | Host: "github.com", | ||
63 | Path: "/login/oauth/access_token", | ||
64 | }, | ||
65 | Header: http.Header{ | ||
66 | "Content-Type": []string{"application/x-www-form-urlencoded"}, | ||
67 | "Accept": []string{"application/json"}, | ||
68 | }, | ||
69 | Body: ioutil.NopCloser(strings.NewReader(url.Values{ | ||
70 | "client_id": []string{a.ClientId}, | ||
71 | "client_secret": []string{a.ClientSecret}, | ||
72 | "code": []string{code}, | ||
73 | }.Encode())), | ||
74 | }) | ||
75 | if err != nil { | ||
76 | return nil, err | ||
77 | } | ||
78 | defer r.Body.Close() | ||
79 | |||
80 | body, err := ioutil.ReadAll(r.Body) | ||
81 | if err != nil { | ||
82 | return nil, err | ||
83 | } | ||
84 | |||
85 | var ret GitHubToken | ||
86 | if err = json.Unmarshal(body, &ret); err != nil { | ||
87 | return nil, err | ||
88 | } | ||
89 | |||
90 | return &ret, nil | ||
91 | } | ||
92 | |||
93 | // GetUsernameWithToken returns the authenticated user's GitHub username given | ||
94 | // an Oauth auth token. | ||
95 | func (a *GitHubAuthenticator) GetUsernameWithToken(accessToken string) (string, error) { | ||
96 | r, err := (&http.Client{}).Do(&http.Request{ | ||
97 | Method: http.MethodGet, | ||
98 | URL: &url.URL{ | ||
99 | Scheme: "https", | ||
100 | Host: "api.github.com", | ||
101 | Path: "/user", | ||
102 | }, | ||
103 | Header: http.Header{ | ||
104 | "Authorization": []string{fmt.Sprintf("token %s", accessToken)}, | ||
105 | }, | ||
106 | }) | ||
107 | if err != nil { | ||
108 | return "", err | ||
109 | } | ||
110 | defer r.Body.Close() | ||
111 | |||
112 | body, err := ioutil.ReadAll(r.Body) | ||
113 | if err != nil { | ||
114 | return "", err | ||
115 | } | ||
116 | |||
117 | var data apiLoginResponse | ||
118 | if err = json.Unmarshal(body, &data); err != nil { | ||
119 | return "", err | ||
120 | } | ||
121 | |||
122 | if data.Username == "" { | ||
123 | return "", fmt.Errorf("No user returned in GitHub response") | ||
124 | } | ||
125 | |||
126 | return data.Username, nil | ||
127 | } | ||
diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..c65cf39 --- /dev/null +++ b/auth/jwt.go | |||
@@ -0,0 +1,123 @@ | |||
1 | package auth | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "fmt" | ||
6 | "time" | ||
7 | |||
8 | "code.crute.us/mcrute/cloud-identity-broker/app/models" | ||
9 | |||
10 | "gopkg.in/square/go-jose.v2" | ||
11 | "gopkg.in/square/go-jose.v2/jwt" | ||
12 | ) | ||
13 | |||
14 | const keyIdHeader = jose.HeaderKey("kid") | ||
15 | |||
16 | type JWTManager struct { | ||
17 | Store models.UserStore | ||
18 | Audience string | ||
19 | TokenExpires time.Duration | ||
20 | } | ||
21 | |||
22 | // Validate performs validate of a JWT for authentication. If the | ||
23 | // authentication was successful it will return the user model for the | ||
24 | // authenticated user. | ||
25 | // | ||
26 | // JWTs are signed by a per-user key on the service side. These keys may be | ||
27 | // generated automatically when issuing a token to the user or, in the case of | ||
28 | // service accounts, they may be generated by the user and validated on the | ||
29 | // service side with the user's public key. | ||
30 | func (m *JWTManager) Validate(j string) (*models.User, error) { | ||
31 | ctx := context.Background() | ||
32 | |||
33 | parsed, err := jwt.ParseSigned(j) | ||
34 | if err != nil { | ||
35 | return nil, err | ||
36 | } | ||
37 | |||
38 | // With a single signature there should be exactly one header. Without that | ||
39 | // we can't get the key id which means we won't be able to get the key and | ||
40 | // validate the signature. | ||
41 | if len(parsed.Headers) != 1 { | ||
42 | return nil, fmt.Errorf("Expected exactly 1 JWT header, got %d", len(parsed.Headers)) | ||
43 | } | ||
44 | |||
45 | kid := parsed.Headers[0].KeyID | ||
46 | if kid == "" { | ||
47 | return nil, fmt.Errorf("No key ID in token header") | ||
48 | } | ||
49 | |||
50 | // We need the subject claim to lookup the user so we can get their key and | ||
51 | // validate the token signature. | ||
52 | untrustedClaims := jwt.Claims{} | ||
53 | if err := parsed.UnsafeClaimsWithoutVerification(&untrustedClaims); err != nil { | ||
54 | return nil, err | ||
55 | } | ||
56 | |||
57 | user, err := m.Store.Get(ctx, untrustedClaims.Subject) | ||
58 | if err != nil { | ||
59 | return nil, err | ||
60 | } | ||
61 | |||
62 | key := user.GetKey(kid) // Will not return invalid keys | ||
63 | if key == nil { | ||
64 | return nil, fmt.Errorf("No key in user record for key id %s", kid) | ||
65 | } | ||
66 | |||
67 | claims := jwt.Claims{} | ||
68 | if err = parsed.Claims(key.PublicKey, &claims); err != nil { | ||
69 | return nil, err | ||
70 | } | ||
71 | |||
72 | if err = claims.Validate(jwt.Expected{ | ||
73 | Audience: jwt.Audience{m.Audience}, | ||
74 | Time: time.Now(), // +/- 1 minute | ||
75 | }); err != nil { | ||
76 | return nil, err | ||
77 | } | ||
78 | |||
79 | // If we made it here then the user matches the public key used to sign the | ||
80 | // token and the claims are verified so just return the user record we | ||
81 | // fetched earlier. | ||
82 | |||
83 | return user, nil | ||
84 | } | ||
85 | |||
86 | // CreateForUser creates a new JWT based on the passed user. In some cases it | ||
87 | // may also need to generate a new SessionKey for the purpose of encrypting | ||
88 | // those JWTs. Callers should be sure to save the returned SessionKey, if there | ||
89 | // is one, to the user when setting the token otherwise the token signature | ||
90 | // will not be able to be validated on subsequent requests. | ||
91 | func (m *JWTManager) CreateForUser(u *models.User) (string, *models.SessionKey, error) { | ||
92 | pk, err := models.GenerateSessionKey(m.TokenExpires) | ||
93 | if err != nil { | ||
94 | return "", nil, err | ||
95 | } | ||
96 | |||
97 | // The ExtraHeaders bit is kind of an ugly hack but there's no way in the | ||
98 | // official API to set the KeyID for single-signer tokens. However, if the | ||
99 | // magic "kid" label is set in the extra headers it will be set as the | ||
100 | // KeyID in the header when that's deserialized. | ||
101 | signer, err := jose.NewSigner(jose.SigningKey{ | ||
102 | Algorithm: jose.ES256, | ||
103 | Key: pk.PrivateKey, | ||
104 | }, &jose.SignerOptions{ | ||
105 | ExtraHeaders: map[jose.HeaderKey]interface{}{ | ||
106 | keyIdHeader: pk.KeyId, | ||
107 | }, | ||
108 | }) | ||
109 | if err != nil { | ||
110 | return "", nil, err | ||
111 | } | ||
112 | |||
113 | now := time.Now() | ||
114 | j, err := jwt.Signed(signer).Claims(jwt.Claims{ | ||
115 | Issuer: m.Audience, | ||
116 | Subject: u.Username, | ||
117 | Audience: jwt.Audience{m.Audience}, | ||
118 | Expiry: jwt.NewNumericDate(now.Add(m.TokenExpires)), | ||
119 | IssuedAt: jwt.NewNumericDate(now), | ||
120 | }).CompactSerialize() | ||
121 | |||
122 | return j, pk, err | ||
123 | } | ||