Files
Christian Richter 16307e036d add new property IdentifierDefaultLogoTargetURI
Signed-off-by: Christian Richter <c.richter@opencloud.eu>
2025-04-28 13:36:13 +02:00

397 lines
13 KiB
Go

/*
* Copyright 2017-2020 Kopano and its licensors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package identifier
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/libregraph/oidc-go"
"github.com/longsleep/rndm"
"golang.org/x/oauth2"
"github.com/libregraph/lico/identity/authorities"
konnectoidc "github.com/libregraph/lico/oidc"
"github.com/libregraph/lico/oidc/payload"
"github.com/libregraph/lico/utils"
)
func (i *Identifier) writeOAuth2Start(rw http.ResponseWriter, req *http.Request, authority *authorities.Details) {
var err error
if authority == nil {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "no authority")
} else if !authority.IsReady() {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2TemporarilyUnavailable, "authority not ready")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Redirect back, with error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 start error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potentially internal information to our RP.
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(req.URL.RawQuery)
query.Del("flow")
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 start")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
sd := &StateData{
State: rndm.GenerateRandomString(32),
RawQuery: req.URL.RawQuery,
ClientID: authority.ClientID,
Ref: authority.ID,
}
// Construct URL to redirect client to external OAuth2 authorize endpoints.
uri, extra, err := authority.MakeRedirectAuthenticationRequestURL(sd.State)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to create authentication request: %w", err)
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 start failed")
return
}
if extra != nil {
sd.Extra = extra
} else {
sd.Extra = make(map[string]interface{})
}
query := uri.Query()
query.Add("client_id", authority.ClientID)
if authority.ResponseType != "" {
query.Add("response_type", authority.ResponseType)
}
if authority.ResponseMode != "" {
query.Add("response_mode", authority.ResponseMode)
}
query.Add("scope", strings.Join(authority.Scopes, " "))
query.Add("redirect_uri", i.oauth2CbEndpointURI.String())
query.Add("nonce", rndm.GenerateRandomString(32))
if authority.CodeChallengeMethod != "" {
codeVerifier := rndm.GenerateRandomString(32)
sd.Extra["code_verifier"] = codeVerifier
codeChallenge := ""
if codeChallenge, err = oidc.MakeCodeChallenge(authority.CodeChallengeMethod, codeVerifier); err == nil {
query.Add("code_challenge", codeChallenge)
query.Add("code_challenge_method", authority.CodeChallengeMethod)
} else {
i.logger.WithError(err).Debugln("identifier failed to create oauth2 code challenge")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to create code challenge")
return
}
}
if display := req.Form.Get("display"); display != "" {
query.Add("display", display)
}
if prompt := req.Form.Get("prompt"); prompt != "" && prompt != oidc.PromptConsent {
// Pass along all prompt values, except consent to external provider and
// handle consent as needed ourselves.
query.Add("prompt", prompt)
}
if maxAge := req.Form.Get("max_age"); maxAge != "" {
query.Add("max_age", maxAge)
}
if uiLocales := req.Form.Get("ui_locales"); uiLocales != "" {
query.Add("ui_locales", uiLocales)
}
if acrValues := req.Form.Get("acr_values"); acrValues != "" {
query.Add("acr_values", acrValues)
}
if claimsLocales := req.Form.Get("claims_locales"); claimsLocales != "" {
query.Add("claims_locales", claimsLocales)
}
// Set cookie which is consumed by the callback later.
err = i.SetStateToStateCookie(req.Context(), rw, "oauth2/cb", sd)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to set oauth2 state cookie")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set cookie")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}
func (i *Identifier) writeOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
// Callbacks from authorization or end session. Validate as specified at
// https://tools.ietf.org/html/rfc6749#section-4.1.2 and https://tools.ietf.org/html/rfc6749#section-10.12.
var err error
var sd *StateData
var user *IdentifiedUser
var userInfoClaims jwt.MapClaims
var authority *authorities.Details
for {
sd, err = i.GetStateFromStateCookie(req.Context(), rw, req, "oauth2/cb", req.Form.Get("state"))
if err != nil {
err = fmt.Errorf("failed to decode oauth2 cb state: %w", err)
break
}
if sd == nil {
err = errors.New("state not found")
break
}
// Load authority with client_id in state.
authority, _ = i.authorities.Lookup(req.Context(), sd.Ref)
if authority == nil {
i.logger.WithField("client_id", sd.ClientID).Debugln("identifier failed to find authority in oauth2 cb")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "unknown client_id")
break
}
if authority.AuthorityType != authorities.AuthorityTypeOIDC {
err = errors.New("unknown authority type")
break
}
// Check incoming state type.
var done bool
done, err = func() (bool, error) {
switch sd.Mode {
case StateModeEndSession:
// Special mode. When in end session, take value from state and
// redirect to it. This completes end session callback.
uri, _ := url.Parse(sd.RawQuery)
if uri == nil {
return false, konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InvalidRequest, "no uri in state")
}
if sd.State != "" {
query := uri.Query()
query.Set("state", sd.State)
uri.RawQuery = query.Encode()
}
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
return true, nil
default:
// Continue further.
}
return false, nil
}()
if err != nil {
break
}
if done {
// Already done, nothing further so return.
return
}
if authority.ResponseType == oidc.ResponseTypeCode ||
authority.ResponseType == oidc.ResponseTypeCodeIDToken ||
authority.ResponseType == oidc.ResponseTypeCodeIDTokenToken {
// Exchange code for ID token.
md := authority.Metadata().(*oidc.WellKnown)
config := &oauth2.Config{
ClientID: authority.ClientID,
ClientSecret: authority.ClientSecret,
RedirectURL: i.oauth2CbEndpointURI.String(),
Endpoint: oauth2.Endpoint{
TokenURL: md.TokenEndpoint,
},
Scopes: authority.Scopes,
}
var httpClient *http.Client
if authority.Insecure {
httpClient = utils.InsecureHTTPClient
} else {
httpClient = utils.DefaultHTTPClient
}
t, exchangeErr := config.Exchange(
context.WithValue(req.Context(), oauth2.HTTPClient, httpClient),
req.Form.Get("code"),
oauth2.SetAuthURLParam("code_verifier",
sd.Extra["code_verifier"].(string)),
)
if exchangeErr != nil {
err = fmt.Errorf("failed to exchange code for token: %w", exchangeErr)
break
}
// Inject found data into request for later parse.
req.Form.Set("access_token", t.AccessToken)
req.Form.Set("token_type", t.TokenType)
req.Form.Set("refresh_token", t.RefreshToken)
if v, ok := t.Extra("expires_in").(string); ok {
req.Form.Set("expires_in", v)
}
if v, ok := t.Extra("id_token").(string); ok {
req.Form.Set("id_token", v)
}
// Fetch userinfo.
uiReq, requestErr := http.NewRequest(http.MethodGet, md.UserInfoEndpoint, http.NoBody)
if requestErr != nil {
err = fmt.Errorf("failed to create userinfo request: %w", requestErr)
break
}
t.SetAuthHeader(uiReq)
uiResp, responseErr := httpClient.Do(uiReq)
if responseErr != nil {
err = fmt.Errorf("failed to get userinfo: %w", responseErr)
break
}
// Decode userinfo as JSON, directly into the claims set.
if decodeErr := json.NewDecoder(uiResp.Body).Decode(&userInfoClaims); decodeErr != nil {
err = fmt.Errorf("failed to decode userinfo response: %w", decodeErr)
uiResp.Body.Close()
break
}
uiResp.Body.Close()
}
// Parse incoming state response.
var authenticationSuccess *payload.AuthenticationSuccess
if authenticationSuccessRaw, parseErr := authority.ParseStateResponse(req, sd.State, sd.Extra); parseErr == nil {
authenticationSuccess = authenticationSuccessRaw.(*payload.AuthenticationSuccess)
} else {
err = parseErr
break
}
// Parse and validate IDToken.
idToken, idTokenParseErr := jwt.ParseWithClaims(authenticationSuccess.IDToken, userInfoClaims, authority.JWTKeyfunc())
if idTokenParseErr != nil {
if authority.Insecure {
i.logger.WithField("client_id", sd.ClientID).WithError(idTokenParseErr).Warnln("identifier ignoring validation error for insecure authority")
} else {
i.logger.WithError(idTokenParseErr).Debugln("identifier failed to validate oauth2 cb id token")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2ServerError, "authority response validation failed")
break
}
}
if claims, _ := idToken.Claims.(jwt.MapClaims); claims == nil {
err = errors.New("invalid id token claims")
break
}
// Lookup username and user.
un, extra, claimsErr := authority.IdentityClaimValue(idToken)
if claimsErr != nil {
i.logger.WithError(claimsErr).Debugln("identifier failed to get username from oauth2 cb id token claims")
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2InsufficientScope, "identity claim not found")
break
}
username := &un
// TODO(longsleep): This flow currently does not provide a hello
// context, means that downwards a backend might fail to resolve the
// user when it requires additional information for multiple backend
// routing.
user, err = i.resolveUser(req.Context(), *username)
if err != nil {
i.logger.WithError(err).WithField("username", *username).Debugln("identifier failed to resolve oauth2 cb user with backend")
// TODO(longsleep): Break on validation error.
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "failed to resolve user")
break
}
if user == nil || user.Subject() == "" {
err = konnectoidc.NewOAuth2Error(oidc.ErrorCodeOAuth2AccessDenied, "no such user")
break
}
var logonRef string
if rawIDToken, ok := extra["RawIDToken"]; ok {
logonRef = rawIDToken.(string)
}
if logonRef != "" {
user.logonRef = &logonRef
}
// Get user meta data.
// TODO(longsleep): This is an additional request to the backend. This
// should be avoided. Best would be if the backend would return everything
// in one shot (TODO in core).
err = i.updateUser(req.Context(), user, authority)
if err != nil {
i.logger.WithError(err).Debugln("identifier failed to update user data in oauth2 cb request")
}
// Set logon time.
user.logonAt = time.Now()
err = i.SetUserToLogonCookie(req.Context(), rw, user)
if err != nil {
i.logger.WithError(err).Errorln("identifier failed to serialize logon ticket in oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
return
}
break
}
if sd == nil {
i.logger.WithError(err).Debugln("identifier oauth2 cb without state")
i.ErrorPage(rw, http.StatusBadRequest, "", "state not found")
return
}
uri, _ := url.Parse(i.authorizationEndpointURI.String())
query, _ := url.ParseQuery(sd.RawQuery)
query.Del("flow")
query.Set("identifier", MustBeSignedIn)
if query.Get("prompt") == oidc.PromptSelectAccount {
// Remove select_acount prompt for our secondary indentifier, it was
// already processed by the external provider.
query.Del("prompt")
}
switch typedErr := err.(type) {
case nil:
// breaks
case *konnectoidc.OAuth2Error:
// Pass along OAuth2 error.
i.logger.WithFields(utils.ErrorAsFields(err)).Debugln("oauth2 cb error")
// NOTE(longsleep): Pass along error ID but not the description to avoid
// leaking potetially internal information to our RP.
query.Set("error", typedErr.ErrorID)
query.Set("error_description", "identifier failed to authenticate")
//breaks
default:
i.logger.WithError(err).Errorln("identifier failed to process oauth2 cb")
i.ErrorPage(rw, http.StatusInternalServerError, "", "oauth2 cb failed")
return
}
uri.RawQuery = query.Encode()
utils.WriteRedirect(rw, http.StatusFound, uri, nil, false)
}