1
0
mirror of https://github.com/oauth2-proxy/oauth2-proxy.git synced 2025-01-26 05:27:28 +02:00

459 lines
14 KiB
Go
Raw Normal View History

2015-11-09 09:28:34 +01:00
package providers
import (
"bytes"
"context"
"fmt"
2015-11-09 09:28:34 +01:00
"net/http"
"net/url"
"strings"
"time"
2018-11-29 14:26:41 +00:00
"golang.org/x/exp/slices"
2018-11-29 14:26:41 +00:00
"github.com/bitly/go-simplejson"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
2015-11-09 09:28:34 +01:00
)
// AzureProvider represents an Azure based Identity Provider
2015-11-09 09:28:34 +01:00
type AzureProvider struct {
*ProviderData
Tenant string
GraphGroupField string
isV2Endpoint bool
2015-11-09 09:28:34 +01:00
}
var _ Provider = (*AzureProvider)(nil)
const (
azureProviderName = "Azure"
azureDefaultScope = "openid"
azureDefaultGraphGroupField = "id"
azureV2Scope = "https://graph.microsoft.com/.default"
)
var (
// Default Login URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/authorize.
azureDefaultLoginURL = &url.URL{
Scheme: "https",
Host: "login.microsoftonline.com",
Path: "/common/oauth2/authorize",
}
// Default Redeem URL for Azure. Pre-parsed URL of https://login.microsoftonline.com/common/oauth2/token.
azureDefaultRedeemURL = &url.URL{
Scheme: "https",
Host: "login.microsoftonline.com",
Path: "/common/oauth2/token",
}
// Default Profile URL for Azure. Pre-parsed URL of https://graph.microsoft.com/v1.0/me.
azureDefaultProfileURL = &url.URL{
Scheme: "https",
Host: "graph.microsoft.com",
Path: "/v1.0/me",
}
)
// NewAzureProvider initiates a new AzureProvider
func NewAzureProvider(p *ProviderData, opts options.AzureOptions) *AzureProvider {
p.setProviderDefaults(providerDefaults{
name: azureProviderName,
loginURL: azureDefaultLoginURL,
redeemURL: azureDefaultRedeemURL,
profileURL: azureDefaultProfileURL,
validateURL: nil,
scope: azureDefaultScope,
})
2015-11-09 09:28:34 +01:00
if p.ValidateURL == nil || p.ValidateURL.String() == "" {
p.ValidateURL = p.ProfileURL
}
p.getAuthorizationHeaderFunc = makeAzureHeader
2015-11-09 09:28:34 +01:00
tenant := "common"
if opts.Tenant != "" {
tenant = opts.Tenant
overrideTenantURL(p.LoginURL, azureDefaultLoginURL, tenant, "authorize")
overrideTenantURL(p.RedeemURL, azureDefaultRedeemURL, tenant, "token")
}
2015-11-09 09:28:34 +01:00
graphGroupField := azureDefaultGraphGroupField
if opts.GraphGroupField != "" {
graphGroupField = opts.GraphGroupField
}
isV2Endpoint := false
if strings.Contains(p.LoginURL.String(), "v2.0") {
isV2Endpoint = true
if strings.Contains(p.Scope, " groups") {
logger.Print("WARNING: `groups` scope is not an accepted scope when using Azure OAuth V2 endpoint. Removing it from the scope list")
p.Scope = strings.ReplaceAll(p.Scope, " groups", "")
}
if !strings.Contains(p.Scope, " "+azureV2Scope) {
// In order to be able to query MS Graph we must pass the ms graph default endpoint
p.Scope += " " + azureV2Scope
}
if p.ProtectedResource != nil && p.ProtectedResource.String() != "" {
logger.Print("WARNING: `--resource` option has no effect when using the Azure OAuth V2 endpoint.")
}
}
return &AzureProvider{
ProviderData: p,
Tenant: tenant,
GraphGroupField: graphGroupField,
isV2Endpoint: isV2Endpoint,
2015-11-09 09:28:34 +01:00
}
}
func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
if current == nil || current.String() == "" || current.String() == defaultURL.String() {
*current = url.URL{
2015-11-09 09:28:34 +01:00
Scheme: "https",
Host: "login.microsoftonline.com",
Path: "/" + tenant + "/oauth2/" + path}
2015-11-09 09:28:34 +01:00
}
}
func getMicrosoftGraphGroupsURL(graphGroupField string) *url.URL {
selectStatement := "$select=displayName,id"
if !slices.Contains([]string{"displayName", "id"}, graphGroupField) {
selectStatement += "," + graphGroupField
}
// Select only security groups. Due to the filter option, count param is mandatory even if unused otherwise
return &url.URL{
Scheme: "https",
Host: "graph.microsoft.com",
Path: "/v1.0/me/transitiveMemberOf",
RawQuery: "$count=true&$filter=securityEnabled+eq+true&" + selectStatement,
}
}
func (p *AzureProvider) GetLoginURL(redirectURI, state, _ string, extraParams url.Values) string {
// In azure oauth v2 there is no resource param so add it only if V1 endpoint
// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
extraParams.Add("resource", p.ProtectedResource.String())
}
a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
return a.String()
}
// Redeem exchanges the OAuth2 authentication token for an ID token
PKCE Support (#1541) * Add the allowed_email_domains and the allowed_groups on the auth_request endpoint + support standard wildcard char for validation with sub-domain and email-domain. Signed-off-by: Valentin Pichard <github@w3st.fr> * Fix provider data initialisation * PKCE Support Adds Code Challenge PKCE support (RFC-7636) and partial Authorization Server Metadata (RFC-8414) for detecting PKCE support. - Introduces new option `--force-code-challenge-method` to force a specific code challenge method (either `S256` or `plain`) for instances when the server has not implemented RFC-8414 in order to detect PKCE support on the discovery document. - In all other cases, if the PKCE support can be determined during discovery then the `code_challenge_methods_supported` is used and S256 is always preferred. - The force command line argument is helpful with some providers like Azure who supports PKCE but does not list it in their discovery document yet. - Initial thought was given to just always attempt PKCE since according to spec additional URL parameters should be dropped by servers which implemented OAuth 2, however other projects found cases in the wild where this causes 500 errors by buggy implementations. See: https://github.com/spring-projects/spring-security/pull/7804#issuecomment-578323810 - Due to the fact that the `code_verifier` must be saved between the redirect and callback, sessions are now created when the redirect takes place with `Authenticated: false`. The session will be recreated and marked as `Authenticated` on callback. - Individual provider implementations can choose to include or ignore code_challenge and code_verifier function parameters passed to them Note: Technically speaking `plain` is not required to be implemented since oauth2-proxy will always be able to handle S256 and servers MUST implement S256 support. > If the client is capable of using "S256", it MUST use "S256", as "S256" > is Mandatory To Implement (MTI) on the server. Clients are permitted > to use "plain" only if they cannot support "S256" for some technical > reason and know via out-of-band configuration that the server supports > "plain". Ref: RFC-7636 Sec 4.2 oauth2-proxy will always use S256 unless the user explicitly forces `plain`. Fixes #1361 * Address PR comments by moving pkce generation * Make PKCE opt-in, move to using the Nonce generater for code verifier * Make PKCE opt-in, move to using the Nonce generater for code verifier * Encrypt CodeVerifier in CSRF Token instead of Session - Update Dex for PKCE support - Expose HTTPBin for further use cases * Correct the tests * Move code challenges into extra params * Correct typo in code challenge method Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Correct the extra space in docs Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Address changelog and new line nits * Add generated docs Co-authored-by: Valentin Pichard <github@w3st.fr> Co-authored-by: Joel Speed <joel.speed@hotmail.co.uk>
2022-03-13 06:08:33 -04:00
func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) {
params, err := p.prepareRedeem(redirectURL, code, codeVerifier)
if err != nil {
return nil, err
}
// blindly try json and x-www-form-urlencoded
var jsonResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresOn int64 `json:"expires_on,string"`
IDToken string `json:"id_token"`
}
err = requests.New(p.RedeemURL.String()).
WithContext(ctx).
WithMethod("POST").
WithBody(bytes.NewBufferString(params.Encode())).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
2020-07-06 17:42:26 +01:00
Do().
UnmarshalInto(&jsonResponse)
if err != nil {
return nil, err
}
session := &sessions.SessionState{
AccessToken: jsonResponse.AccessToken,
IDToken: jsonResponse.IDToken,
RefreshToken: jsonResponse.RefreshToken,
}
2021-03-06 15:33:40 -08:00
session.CreatedAtNow()
session.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
err = p.extractClaimsIntoSession(ctx, session)
if err != nil {
return nil, fmt.Errorf("unable to get email and/or groups claims from token: %v", err)
}
return session, nil
}
// EnrichSession enriches the session state with userID, mail and groups
func (p *AzureProvider) EnrichSession(ctx context.Context, session *sessions.SessionState) error {
err := p.extractClaimsIntoSession(ctx, session)
if err != nil {
logger.Printf("unable to get email and/or groups claims from token: %v", err)
}
if session.Email == "" {
email, err := p.getEmailFromProfileAPI(ctx, session.AccessToken)
if err != nil {
return fmt.Errorf("unable to get email address from profile URL: %v", err)
}
session.Email = email
}
// If using the v2.0 oidc endpoint we're also querying Microsoft Graph
if p.isV2Endpoint {
groups, err := p.getGroupsFromProfileAPI(ctx, session)
if err != nil {
return fmt.Errorf("unable to get groups from Microsoft Graph: %v", err)
}
session.Groups = util.RemoveDuplicateStr(append(session.Groups, groups...))
}
return nil
}
PKCE Support (#1541) * Add the allowed_email_domains and the allowed_groups on the auth_request endpoint + support standard wildcard char for validation with sub-domain and email-domain. Signed-off-by: Valentin Pichard <github@w3st.fr> * Fix provider data initialisation * PKCE Support Adds Code Challenge PKCE support (RFC-7636) and partial Authorization Server Metadata (RFC-8414) for detecting PKCE support. - Introduces new option `--force-code-challenge-method` to force a specific code challenge method (either `S256` or `plain`) for instances when the server has not implemented RFC-8414 in order to detect PKCE support on the discovery document. - In all other cases, if the PKCE support can be determined during discovery then the `code_challenge_methods_supported` is used and S256 is always preferred. - The force command line argument is helpful with some providers like Azure who supports PKCE but does not list it in their discovery document yet. - Initial thought was given to just always attempt PKCE since according to spec additional URL parameters should be dropped by servers which implemented OAuth 2, however other projects found cases in the wild where this causes 500 errors by buggy implementations. See: https://github.com/spring-projects/spring-security/pull/7804#issuecomment-578323810 - Due to the fact that the `code_verifier` must be saved between the redirect and callback, sessions are now created when the redirect takes place with `Authenticated: false`. The session will be recreated and marked as `Authenticated` on callback. - Individual provider implementations can choose to include or ignore code_challenge and code_verifier function parameters passed to them Note: Technically speaking `plain` is not required to be implemented since oauth2-proxy will always be able to handle S256 and servers MUST implement S256 support. > If the client is capable of using "S256", it MUST use "S256", as "S256" > is Mandatory To Implement (MTI) on the server. Clients are permitted > to use "plain" only if they cannot support "S256" for some technical > reason and know via out-of-band configuration that the server supports > "plain". Ref: RFC-7636 Sec 4.2 oauth2-proxy will always use S256 unless the user explicitly forces `plain`. Fixes #1361 * Address PR comments by moving pkce generation * Make PKCE opt-in, move to using the Nonce generater for code verifier * Make PKCE opt-in, move to using the Nonce generater for code verifier * Encrypt CodeVerifier in CSRF Token instead of Session - Update Dex for PKCE support - Expose HTTPBin for further use cases * Correct the tests * Move code challenges into extra params * Correct typo in code challenge method Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Correct the extra space in docs Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Address changelog and new line nits * Add generated docs Co-authored-by: Valentin Pichard <github@w3st.fr> Co-authored-by: Joel Speed <joel.speed@hotmail.co.uk>
2022-03-13 06:08:33 -04:00
func (p *AzureProvider) prepareRedeem(redirectURL, code, codeVerifier string) (url.Values, error) {
params := url.Values{}
if code == "" {
return params, ErrMissingCode
}
clientSecret, err := p.GetClientSecret()
if err != nil {
return params, err
}
params.Add("redirect_uri", redirectURL)
params.Add("client_id", p.ClientID)
params.Add("client_secret", clientSecret)
params.Add("code", code)
params.Add("grant_type", "authorization_code")
PKCE Support (#1541) * Add the allowed_email_domains and the allowed_groups on the auth_request endpoint + support standard wildcard char for validation with sub-domain and email-domain. Signed-off-by: Valentin Pichard <github@w3st.fr> * Fix provider data initialisation * PKCE Support Adds Code Challenge PKCE support (RFC-7636) and partial Authorization Server Metadata (RFC-8414) for detecting PKCE support. - Introduces new option `--force-code-challenge-method` to force a specific code challenge method (either `S256` or `plain`) for instances when the server has not implemented RFC-8414 in order to detect PKCE support on the discovery document. - In all other cases, if the PKCE support can be determined during discovery then the `code_challenge_methods_supported` is used and S256 is always preferred. - The force command line argument is helpful with some providers like Azure who supports PKCE but does not list it in their discovery document yet. - Initial thought was given to just always attempt PKCE since according to spec additional URL parameters should be dropped by servers which implemented OAuth 2, however other projects found cases in the wild where this causes 500 errors by buggy implementations. See: https://github.com/spring-projects/spring-security/pull/7804#issuecomment-578323810 - Due to the fact that the `code_verifier` must be saved between the redirect and callback, sessions are now created when the redirect takes place with `Authenticated: false`. The session will be recreated and marked as `Authenticated` on callback. - Individual provider implementations can choose to include or ignore code_challenge and code_verifier function parameters passed to them Note: Technically speaking `plain` is not required to be implemented since oauth2-proxy will always be able to handle S256 and servers MUST implement S256 support. > If the client is capable of using "S256", it MUST use "S256", as "S256" > is Mandatory To Implement (MTI) on the server. Clients are permitted > to use "plain" only if they cannot support "S256" for some technical > reason and know via out-of-band configuration that the server supports > "plain". Ref: RFC-7636 Sec 4.2 oauth2-proxy will always use S256 unless the user explicitly forces `plain`. Fixes #1361 * Address PR comments by moving pkce generation * Make PKCE opt-in, move to using the Nonce generater for code verifier * Make PKCE opt-in, move to using the Nonce generater for code verifier * Encrypt CodeVerifier in CSRF Token instead of Session - Update Dex for PKCE support - Expose HTTPBin for further use cases * Correct the tests * Move code challenges into extra params * Correct typo in code challenge method Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Correct the extra space in docs Co-authored-by: Joel Speed <Joel.speed@hotmail.co.uk> * Address changelog and new line nits * Add generated docs Co-authored-by: Valentin Pichard <github@w3st.fr> Co-authored-by: Joel Speed <joel.speed@hotmail.co.uk>
2022-03-13 06:08:33 -04:00
if codeVerifier != "" {
params.Add("code_verifier", codeVerifier)
}
// In azure oauth v2 there is no resource param so add it only if V1 endpoint
// https://docs.microsoft.com/en-us/azure/active-directory/azuread-dev/azure-ad-endpoint-comparison#scopes-not-resources
if p.ProtectedResource != nil && p.ProtectedResource.String() != "" && !p.isV2Endpoint {
params.Add("resource", p.ProtectedResource.String())
}
return params, nil
}
// extractClaimsIntoSession tries to extract email and groups claims from either id_token or access token
// when oidc verifier is configured
func (p *AzureProvider) extractClaimsIntoSession(ctx context.Context, session *sessions.SessionState) error {
var s *sessions.SessionState
// First let's verify session token
if err := p.verifySessionToken(ctx, session); err != nil {
return fmt.Errorf("unable to verify token: %v", err)
}
// https://github.com/oauth2-proxy/oauth2-proxy/pull/914#issuecomment-782285814
// https://github.com/AzureAD/azure-activedirectory-library-for-java/issues/117
// due to above issues, id_token may not be signed by AAD
// in that case, we will fallback to access token
var err error
s, err = p.buildSessionFromClaims(session.IDToken, session.AccessToken)
if err != nil || s.Email == "" {
s, err = p.buildSessionFromClaims(session.AccessToken, session.AccessToken)
}
if err != nil {
return fmt.Errorf("unable to get claims from token: %v", err)
}
session.Email = s.Email
if s.Groups != nil {
session.Groups = s.Groups
}
return nil
}
// verifySessionToken tries to validate id_token if present or access token when oidc verifier is configured
func (p *AzureProvider) verifySessionToken(ctx context.Context, session *sessions.SessionState) error {
// Without a verifier there's no way to verify
if p.Verifier == nil {
return nil
}
if session.IDToken != "" {
if _, err := p.Verifier.Verify(ctx, session.IDToken); err != nil {
logger.Printf("unable to verify ID token, fallback to access token: %v", err)
if _, err = p.Verifier.Verify(ctx, session.AccessToken); err != nil {
return fmt.Errorf("unable to verify access token: %v", err)
}
}
} else if _, err := p.Verifier.Verify(ctx, session.AccessToken); err != nil {
return fmt.Errorf("unable to verify access token: %v", err)
}
return nil
}
2021-03-06 15:33:40 -08:00
// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens
func (p *AzureProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) {
if s == nil || s.RefreshToken == "" {
return false, nil
}
err := p.redeemRefreshToken(ctx, s)
if err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %v", err)
}
return true, nil
}
2021-03-06 15:33:40 -08:00
func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) error {
clientSecret, err := p.GetClientSecret()
if err != nil {
return err
}
params := url.Values{}
params.Add("client_id", p.ClientID)
params.Add("client_secret", clientSecret)
params.Add("refresh_token", s.RefreshToken)
params.Add("grant_type", "refresh_token")
var jsonResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresOn int64 `json:"expires_on,string"`
IDToken string `json:"id_token"`
}
err = requests.New(p.RedeemURL.String()).
WithContext(ctx).
WithMethod("POST").
WithBody(bytes.NewBufferString(params.Encode())).
SetHeader("Content-Type", "application/x-www-form-urlencoded").
Do().
UnmarshalInto(&jsonResponse)
if err != nil {
2021-03-06 15:33:40 -08:00
return err
}
s.AccessToken = jsonResponse.AccessToken
s.IDToken = jsonResponse.IDToken
s.RefreshToken = jsonResponse.RefreshToken
2021-03-06 15:33:40 -08:00
s.CreatedAtNow()
s.SetExpiresOn(time.Unix(jsonResponse.ExpiresOn, 0))
err = p.extractClaimsIntoSession(ctx, s)
if err != nil {
logger.Printf("unable to get email and/or groups claims from token: %v", err)
}
2021-03-06 15:33:40 -08:00
return nil
}
func makeAzureHeader(accessToken string) http.Header {
return makeAuthorizationHeader(tokenTypeBearer, accessToken, nil)
2015-11-09 09:28:34 +01:00
}
func (p *AzureProvider) getGroupsFromProfileAPI(ctx context.Context, s *sessions.SessionState) ([]string, error) {
if s.AccessToken == "" {
return nil, fmt.Errorf("missing access token")
}
2017-03-29 09:36:38 -04:00
groupsURL := getMicrosoftGraphGroupsURL(p.GraphGroupField).String()
2017-03-29 09:36:38 -04:00
// Need and extra header while talking with MS Graph. For more context see
// https://docs.microsoft.com/en-us/graph/api/group-list-transitivememberof?view=graph-rest-1.0&tabs=http#request-headers
extraHeader := makeAzureHeader(s.AccessToken)
extraHeader.Add("ConsistencyLevel", "eventual")
2017-03-29 09:36:38 -04:00
var groups []string
for groupsURL != "" {
jsonRequest, err := requests.New(groupsURL).
WithContext(ctx).
WithHeaders(extraHeader).
Do().
UnmarshalSimpleJSON()
if err != nil {
return nil, fmt.Errorf("unable to unmarshal Microsoft Graph response: %v", err)
}
groupsURL, err = jsonRequest.Get("@odata.nextLink").String()
if err != nil {
groupsURL = ""
}
groupsPage := getGroupsFromJSON(jsonRequest, p.GraphGroupField)
groups = append(groups, groupsPage...)
}
return groups, nil
}
func getGroupsFromJSON(json *simplejson.Json, graphGroupField string) []string {
groups := []string{}
for i := range json.Get("value").MustArray() {
value := json.Get("value").GetIndex(i).Get(graphGroupField).MustString()
groups = append(groups, value)
}
return groups
2017-03-29 09:36:38 -04:00
}
func (p *AzureProvider) getEmailFromProfileAPI(ctx context.Context, accessToken string) (string, error) {
if accessToken == "" {
return "", fmt.Errorf("missing access token")
2015-11-09 09:28:34 +01:00
}
json, err := requests.New(p.ProfileURL.String()).
WithContext(ctx).
WithHeaders(makeAzureHeader(accessToken)).
2020-07-06 17:42:26 +01:00
Do().
UnmarshalSimpleJSON()
2015-11-09 09:28:34 +01:00
if err != nil {
return "", err
}
email, err := getEmailFromJSON(json)
if email == "" {
return "", fmt.Errorf("empty email address: %v", err)
}
return email, nil
}
func getEmailFromJSON(json *simplejson.Json) (string, error) {
email, err := json.Get("mail").String()
if err != nil || email == "" {
otherMails, otherMailsErr := json.Get("otherMails").Array()
if len(otherMails) > 0 {
email = otherMails[0].(string)
}
err = otherMailsErr
}
if err != nil || email == "" {
email, err = json.Get("userPrincipalName").String()
if err != nil {
logger.Errorf("unable to find userPrincipalName: %s", err)
return "", err
}
}
return email, nil
}
// ValidateSession validates the AccessToken
func (p *AzureProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
return validateToken(ctx, p, s.AccessToken, makeAzureHeader(s.AccessToken))
}