git.iko.soy/typeable/oauth2-proxy
on master help sign in
289 lines 8.3 kB history raw
1package providers
3import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/rsa"
8 "errors"
9 "fmt"
10 "math/big"
11 "net/http"
12 "net/url"
13 "os"
14 "time"
16 "github.com/go-jose/go-jose/v3"
17 "github.com/golang-jwt/jwt/v5"
18 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
19 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
20 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
23// LoginGovProvider represents an OIDC based Identity Provider
24type LoginGovProvider struct {
25 *ProviderData
27 // TODO (@timothy-spencer): Ideally, the nonce would be in the session state, but the session state
28 // is created only upon code redemption, not during the auth, when this must be supplied.
29 Nonce string
30 JWTKey *rsa.PrivateKey
31 PubJWKURL *url.URL
34var _ Provider = (*LoginGovProvider)(nil)
36// For generating a nonce
37var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
39func randSeq(n int) string {
40 b := make([]rune, n)
41 for i := range b {
42 maxInt := big.NewInt(int64(len(letters)))
43 bigN, err := rand.Int(rand.Reader, maxInt)
44 if err != nil {
45 // This should never happen
46 panic(err)
47 }
48 b[i] = letters[bigN.Int64()]
49 }
50 return string(b)
53const (
54 loginGovProviderName = "login.gov"
55 loginGovDefaultScope = "email openid"
58var (
59 // Default Login URL for LoginGov.
60 // Pre-parsed URL of https://secure.login.gov/openid_connect/authorize.
61 loginGovDefaultLoginURL = &url.URL{
62 Scheme: "https",
63 Host: "secure.login.gov",
64 Path: "/openid_connect/authorize",
65 }
67 // Default Redeem URL for LoginGov.
68 // Pre-parsed URL of https://secure.login.gov/api/openid_connect/token.
69 loginGovDefaultRedeemURL = &url.URL{
70 Scheme: "https",
71 Host: "secure.login.gov",
72 Path: "/api/openid_connect/token",
73 }
75 // Default Profile URL for LoginGov.
76 // Pre-parsed URL of https://graph.loginGov.com/v2.5/me.
77 loginGovDefaultProfileURL = &url.URL{
78 Scheme: "https",
79 Host: "secure.login.gov",
80 Path: "/api/openid_connect/userinfo",
81 }
84// NewLoginGovProvider initiates a new LoginGovProvider
85func NewLoginGovProvider(p *ProviderData, opts options.LoginGovOptions) (*LoginGovProvider, error) {
86 p.setProviderDefaults(providerDefaults{
87 name: loginGovProviderName,
88 loginURL: loginGovDefaultLoginURL,
89 redeemURL: loginGovDefaultRedeemURL,
90 profileURL: loginGovDefaultProfileURL,
91 validateURL: loginGovDefaultProfileURL,
92 scope: loginGovDefaultScope,
93 })
94 provider := &LoginGovProvider{
95 ProviderData: p,
96 Nonce: randSeq(32),
97 }
99 if err := provider.configure(opts); err != nil {
100 return nil, fmt.Errorf("could not configure login.gov provider: %v", err)
101 }
102 return provider, nil
105func (p *LoginGovProvider) configure(opts options.LoginGovOptions) error {
106 pubJWKURL, err := url.Parse(opts.PubJWKURL)
107 if err != nil {
108 return fmt.Errorf("could not parse Public JWK URL: %v", err)
109 }
110 p.PubJWKURL = pubJWKURL
112 // JWT key can be supplied via env variable or file in the filesystem, but not both.
113 switch {
114 case opts.JWTKey != "" && opts.JWTKeyFile != "":
115 return errors.New("cannot set both jwt-key and jwt-key-file options")
116 case opts.JWTKey == "" && opts.JWTKeyFile == "":
117 return errors.New("login.gov provider requires a private key for signing JWTs")
118 case opts.JWTKey != "":
119 // The JWT Key is in the commandline argument
120 signKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(opts.JWTKey))
121 if err != nil {
122 return fmt.Errorf("could not parse RSA Private Key PEM: %v", err)
123 }
124 p.JWTKey = signKey
125 case opts.JWTKeyFile != "":
126 // The JWT key is in the filesystem
127 keyData, err := os.ReadFile(opts.JWTKeyFile)
128 if err != nil {
129 return fmt.Errorf("could not read key file: %v", opts.JWTKeyFile)
130 }
131 signKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyData)
132 if err != nil {
133 return fmt.Errorf("could not parse private key from PEM file: %v", opts.JWTKeyFile)
134 }
135 p.JWTKey = signKey
136 }
137 return nil
140type loginGovCustomClaims struct {
141 Acr string `json:"acr"`
142 Nonce string `json:"nonce"`
143 Email string `json:"email"`
144 EmailVerified bool `json:"email_verified"`
145 GivenName string `json:"given_name"`
146 FamilyName string `json:"family_name"`
147 Birthdate string `json:"birthdate"`
148 AtHash string `json:"at_hash"`
149 CHash string `json:"c_hash"`
150 jwt.RegisteredClaims
153// checkNonce checks the nonce in the id_token
154func checkNonce(idToken string, p *LoginGovProvider) (err error) {
155 token, err := jwt.ParseWithClaims(idToken, &loginGovCustomClaims{}, func(_ *jwt.Token) (interface{}, error) {
156 var pubkeys jose.JSONWebKeySet
157 rerr := requests.New(p.PubJWKURL.String()).Do().UnmarshalInto(&pubkeys)
158 if rerr != nil {
159 return nil, rerr
160 }
161 return pubkeys.Keys[0].Key, nil
162 })
163 if err != nil {
164 return
165 }
167 claims := token.Claims.(*loginGovCustomClaims)
168 if claims.Nonce != p.Nonce {
169 err = fmt.Errorf("nonce validation failed")
170 return
171 }
172 return
175func emailFromUserInfo(ctx context.Context, accessToken string, userInfoEndpoint string) (string, error) {
176 // parse the user attributes from the data we got and make sure that
177 // the email address has been validated.
178 var emailData struct {
179 Email string `json:"email"`
180 EmailVerified bool `json:"email_verified"`
181 }
183 // query the user info endpoint for user attributes
184 err := requests.New(userInfoEndpoint).
185 WithContext(ctx).
186 SetHeader("Authorization", tokenTypeBearer+" "+accessToken).
187 Do().
188 UnmarshalInto(&emailData)
189 if err != nil {
190 return "", err
191 }
193 email := emailData.Email
194 if email == "" {
195 return "", fmt.Errorf("missing email")
196 }
198 if !emailData.EmailVerified {
199 return "", fmt.Errorf("email %s not listed as verified", email)
200 }
202 return email, nil
205// Redeem exchanges the OAuth2 authentication token for an ID token
206func (p *LoginGovProvider) Redeem(ctx context.Context, _, code, codeVerifier string) (*sessions.SessionState, error) {
207 if code == "" {
208 return nil, ErrMissingCode
209 }
211 claims := &jwt.RegisteredClaims{
212 Issuer: p.ClientID,
213 Subject: p.ClientID,
214 Audience: jwt.ClaimStrings{p.RedeemURL.String()},
215 ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
216 }
217 token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
218 ss, err := token.SignedString(p.JWTKey)
219 if err != nil {
220 return nil, err
221 }
223 params := url.Values{}
224 params.Add("client_assertion", ss)
225 params.Add("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
226 params.Add("code", code)
227 params.Add("grant_type", "authorization_code")
228 if codeVerifier != "" {
229 params.Add("code_verifier", codeVerifier)
230 }
232 // Get the token from the body that we got from the token endpoint.
233 var jsonResponse struct {
234 AccessToken string `json:"access_token"`
235 IDToken string `json:"id_token"`
236 TokenType string `json:"token_type"`
237 ExpiresIn int64 `json:"expires_in"`
238 }
239 err = requests.New(p.RedeemURL.String()).
240 WithContext(ctx).
241 WithMethod(http.MethodPost).
242 WithBody(bytes.NewBufferString(params.Encode())).
243 SetHeader("Content-Type", "application/x-www-form-urlencoded").
244 Do().
245 UnmarshalInto(&jsonResponse)
246 if err != nil {
247 return nil, err
248 }
250 // check nonce here
251 err = checkNonce(jsonResponse.IDToken, p)
252 if err != nil {
253 return nil, err
254 }
256 // Get the email address
257 var email string
258 email, err = emailFromUserInfo(ctx, jsonResponse.AccessToken, p.ProfileURL.String())
259 if err != nil {
260 return nil, err
261 }
263 session := &sessions.SessionState{
264 AccessToken: jsonResponse.AccessToken,
265 IDToken: jsonResponse.IDToken,
266 Email: email,
267 }
269 session.CreatedAtNow()
270 session.ExpiresIn(time.Duration(jsonResponse.ExpiresIn) * time.Second)
272 return session, nil
275// GetLoginURL overrides GetLoginURL to add login.gov parameters
276func (p *LoginGovProvider) GetLoginURL(redirectURI, state, _ string, extraParams url.Values) string {
277 if len(extraParams["acr_values"]) == 0 {
278 acr := "http://idmanagement.gov/ns/assurance/loa/1"
279 extraParams.Add("acr_values", acr)
280 }
281 extraParams.Add("nonce", p.Nonce)
282 a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
283 return a.String()
286// ValidateSession validates the AccessToken
287func (p *LoginGovProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
288 return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken))