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:
@ -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
|
||||
|
78
internal/outpost/proxyv2/application/auth.go
Normal file
78
internal/outpost/proxyv2/application/auth.go
Normal 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
|
||||
}
|
59
internal/outpost/proxyv2/application/auth_basic.go
Normal file
59
internal/outpost/proxyv2/application/auth_basic.go
Normal 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
|
||||
}
|
62
internal/outpost/proxyv2/application/auth_bearer.go
Normal file
62
internal/outpost/proxyv2/application/auth_bearer.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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"))
|
||||
|
@ -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 != "" {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Reference in New Issue
Block a user