git.iko.soy/typeable/oauth2-proxy
on remove-gap-headers help sign in
236 lines 6.4 kB history raw
1package providers
3import (
4 "bytes"
5 "context"
6 "crypto/rsa"
7 "errors"
8 "fmt"
9 "math/rand"
10 "net/url"
11 "time"
13 "github.com/dgrijalva/jwt-go"
14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
15 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
16 "gopkg.in/square/go-jose.v2"
19// LoginGovProvider represents an OIDC based Identity Provider
20type LoginGovProvider struct {
21 *ProviderData
23 // TODO (@timothy-spencer): Ideally, the nonce would be in the session state, but the session state
24 // is created only upon code redemption, not during the auth, when this must be supplied.
25 Nonce string
26 JWTKey *rsa.PrivateKey
27 PubJWKURL *url.URL
30var _ Provider = (*LoginGovProvider)(nil)
32// For generating a nonce
33var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
35func randSeq(n int) string {
36 b := make([]rune, n)
37 for i := range b {
38 b[i] = letters[rand.Intn(len(letters))]
39 }
40 return string(b)
43const (
44 loginGovProviderName = "login.gov"
45 loginGovDefaultScope = "email openid"
48var (
49 // Default Login URL for LoginGov.
50 // Pre-parsed URL of https://secure.login.gov/openid_connect/authorize.
51 loginGovDefaultLoginURL = &url.URL{
52 Scheme: "https",
53 Host: "secure.login.gov",
54 Path: "/openid_connect/authorize",
55 }
57 // Default Redeem URL for LoginGov.
58 // Pre-parsed URL of https://secure.login.gov/api/openid_connect/token.
59 loginGovDefaultRedeemURL = &url.URL{
60 Scheme: "https",
61 Host: "secure.login.gov",
62 Path: "/api/openid_connect/token",
63 }
65 // Default Profile URL for LoginGov.
66 // Pre-parsed URL of https://graph.loginGov.com/v2.5/me.
67 loginGovDefaultProfileURL = &url.URL{
68 Scheme: "https",
69 Host: "secure.login.gov",
70 Path: "/api/openid_connect/userinfo",
71 }
74// NewLoginGovProvider initiates a new LoginGovProvider
75func NewLoginGovProvider(p *ProviderData) *LoginGovProvider {
76 p.setProviderDefaults(providerDefaults{
77 name: loginGovProviderName,
78 loginURL: loginGovDefaultLoginURL,
79 redeemURL: loginGovDefaultRedeemURL,
80 profileURL: loginGovDefaultProfileURL,
81 validateURL: nil,
82 scope: loginGovDefaultScope,
83 })
84 return &LoginGovProvider{
85 ProviderData: p,
86 Nonce: randSeq(32),
87 }
90type loginGovCustomClaims struct {
91 Acr string `json:"acr"`
92 Nonce string `json:"nonce"`
93 Email string `json:"email"`
94 EmailVerified bool `json:"email_verified"`
95 GivenName string `json:"given_name"`
96 FamilyName string `json:"family_name"`
97 Birthdate string `json:"birthdate"`
98 AtHash string `json:"at_hash"`
99 CHash string `json:"c_hash"`
100 jwt.StandardClaims
103// checkNonce checks the nonce in the id_token
104func checkNonce(idToken string, p *LoginGovProvider) (err error) {
105 token, err := jwt.ParseWithClaims(idToken, &loginGovCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
106 var pubkeys jose.JSONWebKeySet
107 rerr := requests.New(p.PubJWKURL.String()).Do().UnmarshalInto(&pubkeys)
108 if rerr != nil {
109 return nil, rerr
110 }
111 return pubkeys.Keys[0].Key, nil
112 })
113 if err != nil {
114 return
115 }
117 claims := token.Claims.(*loginGovCustomClaims)
118 if claims.Nonce != p.Nonce {
119 err = fmt.Errorf("nonce validation failed")
120 return
121 }
122 return
125func emailFromUserInfo(ctx context.Context, accessToken string, userInfoEndpoint string) (string, error) {
126 // parse the user attributes from the data we got and make sure that
127 // the email address has been validated.
128 var emailData struct {
129 Email string `json:"email"`
130 EmailVerified bool `json:"email_verified"`
131 }
133 // query the user info endpoint for user attributes
134 err := requests.New(userInfoEndpoint).
135 WithContext(ctx).
136 SetHeader("Authorization", "Bearer "+accessToken).
137 Do().
138 UnmarshalInto(&emailData)
139 if err != nil {
140 return "", err
141 }
143 email := emailData.Email
144 if email == "" {
145 return "", fmt.Errorf("missing email")
146 }
148 if !emailData.EmailVerified {
149 return "", fmt.Errorf("email %s not listed as verified", email)
150 }
152 return email, nil
155// Redeem exchanges the OAuth2 authentication token for an ID token
156func (p *LoginGovProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
157 if code == "" {
158 err = errors.New("missing code")
159 return
160 }
162 claims := &jwt.StandardClaims{
163 Issuer: p.ClientID,
164 Subject: p.ClientID,
165 Audience: p.RedeemURL.String(),
166 ExpiresAt: time.Now().Add(5 * time.Minute).Unix(),
167 Id: randSeq(32),
168 }
169 token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
170 ss, err := token.SignedString(p.JWTKey)
171 if err != nil {
172 return
173 }
175 params := url.Values{}
176 params.Add("client_assertion", ss)
177 params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
178 params.Add("code", code)
179 params.Add("grant_type", "authorization_code")
181 // Get the token from the body that we got from the token endpoint.
182 var jsonResponse struct {
183 AccessToken string `json:"access_token"`
184 IDToken string `json:"id_token"`
185 TokenType string `json:"token_type"`
186 ExpiresIn int64 `json:"expires_in"`
187 }
188 err = requests.New(p.RedeemURL.String()).
189 WithContext(ctx).
190 WithMethod("POST").
191 WithBody(bytes.NewBufferString(params.Encode())).
192 SetHeader("Content-Type", "application/x-www-form-urlencoded").
193 Do().
194 UnmarshalInto(&jsonResponse)
195 if err != nil {
196 return nil, err
197 }
199 // check nonce here
200 err = checkNonce(jsonResponse.IDToken, p)
201 if err != nil {
202 return
203 }
205 // Get the email address
206 var email string
207 email, err = emailFromUserInfo(ctx, jsonResponse.AccessToken, p.ProfileURL.String())
208 if err != nil {
209 return
210 }
212 created := time.Now()
213 expires := time.Now().Add(time.Duration(jsonResponse.ExpiresIn) * time.Second).Truncate(time.Second)
215 // Store the data that we found in the session state
216 s = &sessions.SessionState{
217 AccessToken: jsonResponse.AccessToken,
218 IDToken: jsonResponse.IDToken,
219 CreatedAt: &created,
220 ExpiresOn: &expires,
221 Email: email,
222 }
223 return
226// GetLoginURL overrides GetLoginURL to add login.gov parameters
227func (p *LoginGovProvider) GetLoginURL(redirectURI, state string) string {
228 extraParams := url.Values{}
229 if p.AcrValues == "" {
230 acr := "http://idmanagement.gov/ns/assurance/loa/1"
231 extraParams.Add("acr_values", acr)
232 }
233 extraParams.Add("nonce", p.Nonce)
234 a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
235 return a.String()