providers/proxy: add initial header token auth (#4421)

* initial implementation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* check for openid/profile claims

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* include jwks sources in proxy provider

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add web ui for jwks

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only show sources with JWKS data configured

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix introspection tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start basic

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add basic auth

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add docs, update admonitions

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add client_id to api, add tab for auth

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update locale

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2023-01-13 16:22:03 +01:00
committed by GitHub
parent 31c6ea9fda
commit cd12e177ea
54 changed files with 830 additions and 162 deletions

View File

@ -120,7 +120,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, cs *ak.CryptoStore
}))
mux.Use(func(inner http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
c, _ := a.getClaims(r)
c, _ := a.checkAuth(rw, r)
user := ""
if c != nil {
user = c.PreferredUsername

View File

@ -0,0 +1,78 @@
package application
import (
"fmt"
"net/http"
"goauthentik.io/internal/outpost/proxyv2/constants"
)
const HeaderAuthorization = "Authorization"
const AuthBearer = "Bearer "
// checkAuth Get claims which are currently in session
// Returns an error if the session can't be loaded or the claims can't be parsed/type-cast
func (a *Application) checkAuth(rw http.ResponseWriter, r *http.Request) (*Claims, error) {
s, _ := a.sessions.Get(r, constants.SessionName)
c := a.getClaimsFromSession(r)
if c != nil {
return c, nil
}
if rw == nil {
return nil, fmt.Errorf("no response writer")
}
// Check bearer token if set
bearer := a.checkAuthHeaderBearer(r)
if bearer != "" {
a.log.Trace("checking bearer token")
tc := a.attemptBearerAuth(r, bearer)
if tc != nil {
s.Values[constants.SessionClaims] = tc.Claims
err := s.Save(r, rw)
if err != nil {
return nil, err
}
r.Header.Del(HeaderAuthorization)
return &tc.Claims, nil
}
a.log.Trace("no/invalid bearer token")
}
// Check basic auth if set
username, password, basicSet := r.BasicAuth()
if basicSet {
a.log.Trace("checking basic auth")
tc := a.attemptBasicAuth(username, password)
if tc != nil {
s.Values[constants.SessionClaims] = *tc
err := s.Save(r, rw)
if err != nil {
return nil, err
}
r.Header.Del(HeaderAuthorization)
return tc, nil
}
a.log.Trace("no/invalid basic auth")
}
return nil, fmt.Errorf("failed to get claims from session")
}
func (a *Application) getClaimsFromSession(r *http.Request) *Claims {
s, err := a.sessions.Get(r, constants.SessionName)
if err != nil {
// err == user has no session/session is not valid, reject
return nil
}
claims, ok := s.Values[constants.SessionClaims]
if claims == nil || !ok {
// no claims saved, reject
return nil
}
c, ok := claims.(Claims)
if !ok {
return nil
}
return &c
}

View File

@ -0,0 +1,59 @@
package application
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
func (a *Application) attemptBasicAuth(username, password string) *Claims {
values := url.Values{
"grant_type": []string{"client_credentials"},
"client_id": []string{a.oauthConfig.ClientID},
"username": []string{username},
"password": []string{password},
"scope": []string{strings.Join(a.oauthConfig.Scopes, " ")},
}
req, err := http.NewRequest("POST", a.endpoint.TokenURL, strings.NewReader(values.Encode()))
if err != nil {
a.log.WithError(err).Warning("failed to create token request")
return nil
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := a.httpClient.Do(req)
if err != nil || res.StatusCode > 200 {
a.log.WithError(err).Warning("failed to send token request")
return nil
}
var token TokenResponse
err = json.NewDecoder(res.Body).Decode(&token)
if err != nil {
a.log.WithError(err).Warning("failed to parse token response")
return nil
}
// Parse and verify ID Token payload.
idToken, err := a.tokenVerifier.Verify(context.Background(), token.IDToken)
if err != nil {
a.log.WithError(err).Warning("failed to verify token")
return nil
}
// Extract custom claims
var claims *Claims
if err := idToken.Claims(&claims); err != nil {
a.log.WithError(err).Warning("failed to convert token to claims")
return nil
}
if claims.Proxy == nil {
claims.Proxy = &ProxyClaims{}
}
claims.RawToken = token.IDToken
return claims
}

View File

@ -0,0 +1,62 @@
package application
import (
"encoding/json"
"net/http"
"net/url"
"strings"
)
func (a *Application) checkAuthHeaderBearer(r *http.Request) string {
auth := r.Header.Get(HeaderAuthorization)
if auth == "" {
return ""
}
if len(auth) < len(AuthBearer) || !strings.EqualFold(auth[:len(AuthBearer)], AuthBearer) {
return ""
}
return auth[len(AuthBearer):]
}
type TokenIntrospectionResponse struct {
Claims
Scope string `json:"scope"`
Active bool `json:"active"`
ClientID string `json:"client_id"`
}
func (a *Application) attemptBearerAuth(r *http.Request, token string) *TokenIntrospectionResponse {
values := url.Values{
"client_id": []string{a.oauthConfig.ClientID},
"client_secret": []string{a.oauthConfig.ClientSecret},
"token": []string{token},
}
req, err := http.NewRequest("POST", a.endpoint.TokenIntrospection, strings.NewReader(values.Encode()))
if err != nil {
a.log.WithError(err).Warning("failed to create introspection request")
return nil
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := a.httpClient.Do(req)
if err != nil || res.StatusCode > 200 {
a.log.WithError(err).Warning("failed to send introspection request")
return nil
}
intro := TokenIntrospectionResponse{}
err = json.NewDecoder(res.Body).Decode(&intro)
if err != nil {
a.log.WithError(err).Warning("failed to parse introspection response")
return nil
}
if !intro.Active {
a.log.Warning("token is not active")
return nil
}
if !strings.Contains(intro.Scope, "openid") || !strings.Contains(intro.Scope, "profile") {
a.log.Error("token missing openid or profile scope")
return nil
}
intro.RawToken = token
a.log.Trace("successfully introspected bearer token")
return &intro
}

View File

@ -12,6 +12,7 @@ import (
type OIDCEndpoint struct {
oauth2.Endpoint
TokenIntrospection string
EndSessionEndpoint string
JwksUri string
}
@ -67,5 +68,6 @@ func GetOIDCEndpoint(p api.ProxyOutpostConfig, authentikHost string) OIDCEndpoin
ep.AuthURL = authU.String()
ep.EndSessionEndpoint = endU.String()
ep.JwksUri = jwksU.String()
ep.TokenIntrospection = p.OidcConfiguration.IntrospectionEndpoint
return ep
}

View File

@ -14,7 +14,7 @@ type ErrorPageData struct {
}
func (a *Application) ErrorPage(rw http.ResponseWriter, r *http.Request, err string) {
claims, _ := a.getClaims(r)
claims, _ := a.checkAuth(rw, r)
data := ErrorPageData{
Title: "Bad Gateway",
Message: "Error proxying to upstream server",

View File

@ -15,7 +15,6 @@ import (
func (a *Application) addHeaders(headers http.Header, c *Claims) {
// https://goauthentik.io/docs/providers/proxy/proxy
headers.Set("X-authentik-username", c.PreferredUsername)
headers.Set("X-authentik-groups", strings.Join(c.Groups, "|"))
headers.Set("X-authentik-email", c.Email)

View File

@ -49,7 +49,7 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque
return
}
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
@ -100,7 +100,7 @@ func (a *Application) forwardHandleCaddy(rw http.ResponseWriter, r *http.Request
return
}
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
@ -139,7 +139,7 @@ func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request
return
}
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))
@ -175,7 +175,7 @@ func (a *Application) forwardHandleEnvoy(rw http.ResponseWriter, r *http.Request
r.URL.Host = r.Host
fwd := r.URL
// Check if we're authenticated, or the request path is on the allowlist
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if claims != nil && err == nil {
a.addHeaders(rw.Header(), claims)
rw.Header().Set("User-Agent", r.Header.Get("User-Agent"))

View File

@ -33,10 +33,11 @@ func (a *Application) configureProxy() error {
rp.ErrorHandler = a.newProxyErrorHandler()
rp.ModifyResponse = a.proxyModifyResponse
a.mux.PathPrefix("/").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if claims == nil && a.IsAllowlisted(r.URL) {
a.log.Trace("path can be accessed without authentication")
} else if claims == nil && err != nil {
a.log.WithError(err).Trace("no claims")
a.redirectToStart(rw, r)
return
} else {
@ -67,7 +68,7 @@ func (a *Application) configureProxy() error {
func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) {
return func(r *http.Request) {
r.Header.Set("X-Forwarded-Host", r.Host)
claims, _ := a.getClaims(r)
claims, _ := a.checkAuth(nil, r)
r.URL.Scheme = ou.Scheme
r.URL.Host = ou.Host
if claims != nil && claims.Proxy != nil && claims.Proxy.BackendOverride != "" {

View File

@ -50,7 +50,7 @@ func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request) {
// and if we do we don't do anything here
currentState, ok := s.Values[constants.SessionOAuthState].(string)
if ok {
claims, err := a.getClaims(r)
claims, err := a.checkAuth(rw, r)
if err != nil && claims != nil {
a.log.Trace("auth start request with existing authenticated session")
a.redirect(rw, r)

View File

@ -50,6 +50,9 @@ func (a *Application) redeemCallback(savedState string, u *url.URL, c context.Co
if err := idToken.Claims(&claims); err != nil {
return nil, err
}
if claims.Proxy == nil {
claims.Proxy = &ProxyClaims{}
}
claims.RawToken = rawIDToken
return claims, nil
}

View File

@ -1,7 +1,6 @@
package application
import (
"fmt"
"net/http"
"net/url"
"path"
@ -77,26 +76,6 @@ func (a *Application) redirect(rw http.ResponseWriter, r *http.Request) {
http.Redirect(rw, r, redirect, http.StatusFound)
}
// getClaims Get claims which are currently in session
// Returns an error if the session can't be loaded or the claims can't be parsed/type-cast
func (a *Application) getClaims(r *http.Request) (*Claims, error) {
s, err := a.sessions.Get(r, constants.SessionName)
if err != nil {
// err == user has no session/session is not valid, reject
return nil, fmt.Errorf("invalid session")
}
claims, ok := s.Values[constants.SessionClaims]
if claims == nil || !ok {
// no claims saved, reject
return nil, fmt.Errorf("invalid session")
}
c, ok := claims.(Claims)
if !ok {
return nil, fmt.Errorf("invalid session")
}
return &c, nil
}
// toString Generic to string function, currently supports actual strings and integers
func toString(in interface{}) string {
switch v := in.(type) {