Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 40 additions & 11 deletions pkg/provider/authentik/authentik.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ type Client struct {
provider.ValidateBase

client *provider.HTTPClient
// fido performs WebAuthn (FIDO U2F) assertions against a hardware security
// key. It is an interface so tests can substitute a fake device.
fido fidoAuthenticator
}

var logger = logrus.WithField("provider", "authentik")
Expand All @@ -40,13 +43,15 @@ func New(idpAccount *cfg.IDPAccount) (*Client, error) {
}
return &Client{
client: client,
fido: u2fAuthenticator{},
}, nil
}

// Authenticate Log into authentik and returns a SAML response
func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
ctx := &authentikContext{
loginDetails: loginDetails,
url: loginDetails.URL,
}
samlResponse, err := kc.auth(ctx)
if err != nil {
Expand All @@ -58,8 +63,8 @@ func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error)

// auth Authentication
func (kc *Client) auth(ctx *authentikContext) (string, error) {
logger.Debug("[GET] ", ctx.loginDetails.URL)
res, err := kc.client.Get(ctx.loginDetails.URL)
logger.Debug("[GET] ", ctx.url)
res, err := kc.client.Get(ctx.url)
if err != nil {
return "", errors.Wrap(err, "error retrieving initial url")
}
Expand Down Expand Up @@ -104,7 +109,7 @@ func (kc *Client) processQuery(ctx *authentikContext) (string, error) {
var next string
var err error

next, err = queryNextURL(ctx.loginDetails.URL)
next, err = queryNextURL(ctx.url)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -134,8 +139,8 @@ func (kc *Client) processQuery(ctx *authentikContext) (string, error) {

// queryNext Do query and submit infos
func (kc *Client) queryNext(ctx *authentikContext) (bool, string, error) {
logger.Debug("[GET] ", ctx.loginDetails.URL)
res, err := kc.client.Get(ctx.loginDetails.URL)
logger.Debug("[GET] ", ctx.url)
res, err := kc.client.Get(ctx.url)
if err != nil {
return false, "", err
}
Expand Down Expand Up @@ -176,13 +181,34 @@ func (kc *Client) queryNext(ctx *authentikContext) (bool, string, error) {

// doPostQuery For all data setting operations
func (kc *Client) doPostQuery(ctx *authentikContext, payload *authentikPayload) (string, error) {
data, err := getLoginJSON(ctx.loginDetails, payload)
if err != nil {
return "", err
var data []byte
var err error

// Prefer a hardware security key for the authenticator-validate stage when no
// explicit MFA token was supplied and the stage offers a webauthn device.
if payload.Component == "ak-stage-authenticator-validate" && ctx.loginDetails.MFAToken == "" {
var opts *webAuthnRequestOptions
opts, err = payload.webAuthnChallenge()
if err != nil {
return "", err
}
if opts != nil {
data, err = kc.signWebAuthnAssertion(payload.Component, opts)
if err != nil {
return "", err
}
}
}

logger.Debug("[POST]", ctx.loginDetails.URL)
res, err := kc.client.Post(ctx.loginDetails.URL, "application/json", bytes.NewReader(data))
if data == nil {
data, err = getLoginJSON(ctx.loginDetails, payload)
if err != nil {
return "", err
}
}

logger.Debug("[POST]", ctx.url)
res, err := kc.client.Post(ctx.url, "application/json", bytes.NewReader(data))
if err != nil {
return "", err
}
Expand All @@ -203,7 +229,10 @@ func (kc *Client) doPostQuery(ctx *authentikContext, payload *authentikPayload)
return "", errors.New(errMsg)
}
loc, err := res.Location()
return loc.String(), err
if err != nil {
return "", errors.Wrapf(err, "unexpected response (status %d) from authentik flow", res.StatusCode)
}
return loc.String(), nil
}

// getLoginJSON Generate the login json
Expand Down
228 changes: 228 additions & 0 deletions pkg/provider/authentik/authentik_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
package authentik

import (
"encoding/base64"
"encoding/json"
"testing"

"github.com/h2non/gock"
"github.com/stretchr/testify/assert"

"github.com/versent/saml2aws/v2/pkg/cfg"
"github.com/versent/saml2aws/v2/pkg/creds"
"github.com/versent/saml2aws/v2/pkg/provider/okta"
)

// fakeFido is a test double for the FIDO hardware client so WebAuthn tests do
// not need a real security key.
type fakeFido struct {
assertion *okta.SignedAssertion
err error

gotChallenge string
gotRpID string
gotKeyHandle string
}

func (f *fakeFido) challengeU2F(challenge, rpID, keyHandle string) (*okta.SignedAssertion, error) {
f.gotChallenge = challenge
f.gotRpID = rpID
f.gotKeyHandle = keyHandle
return f.assertion, f.err
}

func Test_getLoginJSON(t *testing.T) {
assert := assert.New(t)
loginDetails := &creds.LoginDetails{
Expand Down Expand Up @@ -50,6 +71,75 @@ func Test_getLoginJSON(t *testing.T) {
assert.NotNil(err)
}

func Test_signWebAuthnAssertion(t *testing.T) {
assert := assert.New(t)

fake := &fakeFido{assertion: &okta.SignedAssertion{
ClientData: "client-data-base64url",
SignatureData: base64.StdEncoding.EncodeToString([]byte("signature")),
AuthenticatorData: base64.StdEncoding.EncodeToString([]byte("authdata")),
}}
kc := &Client{fido: fake}

opts := &webAuthnRequestOptions{
Challenge: "Y2hhbGxlbmdl",
RpID: "id.example.com",
UserVerification: "preferred",
AllowCredentials: []webAuthnCredentialDescriptor{
{ID: "cred-id", Type: "public-key", Transports: []string{"usb"}},
},
}

b, err := kc.signWebAuthnAssertion("ak-stage-authenticator-validate", opts)
assert.Nil(err)

// The challenge, rpId and credential id are forwarded to the device.
assert.Equal("Y2hhbGxlbmdl", fake.gotChallenge)
assert.Equal("id.example.com", fake.gotRpID)
assert.Equal("cred-id", fake.gotKeyHandle)

var body webAuthnAssertion
assert.Nil(json.Unmarshal(b, &body))
assert.Equal("ak-stage-authenticator-validate", body.Component)
assert.Equal("cred-id", body.WebAuthn.ID)
assert.Equal("cred-id", body.WebAuthn.RawID)
assert.Equal("public-key", body.WebAuthn.Type)
assert.Equal("client-data-base64url", body.WebAuthn.Response.ClientDataJSON)
assert.Equal(base64.RawURLEncoding.EncodeToString([]byte("authdata")), body.WebAuthn.Response.AuthenticatorData)
assert.Equal(base64.RawURLEncoding.EncodeToString([]byte("signature")), body.WebAuthn.Response.Signature)
assert.Nil(body.WebAuthn.Response.UserHandle)
}

func Test_signWebAuthnAssertion_noCredentials(t *testing.T) {
assert := assert.New(t)
kc := &Client{fido: &fakeFido{}}
_, err := kc.signWebAuthnAssertion("ak-stage-authenticator-validate", &webAuthnRequestOptions{})
assert.NotNil(err)
}

// Test_doPostQuery_noLocation ensures a non-redirect response without a Location
// header (e.g. a 403/500 from a WAF or upstream) yields a clean error rather than
// a nil pointer dereference on res.Location().
func Test_doPostQuery_noLocation(t *testing.T) {
defer gock.Off()
gock.New("http://127.0.0.1").
Post("/api/v3/flows/executor/default-authentication-flow").
Reply(403) // no Location header

client, _ := New(&cfg.IDPAccount{})
gock.InterceptClient(&client.client.Client)

ctx := &authentikContext{
loginDetails: &creds.LoginDetails{Username: "user", Password: "pwd"},
url: "http://127.0.0.1/api/v3/flows/executor/default-authentication-flow/",
}
payload := &authentikPayload{Component: "ak-stage-password", Type: "native"}

assert := assert.New(t)
_, err := client.doPostQuery(ctx, payload)
assert.NotNil(err)
}

func Test_queryNextURL(t *testing.T) {
assert := assert.New(t)
url, err := queryNextURL("https://127.0.0.1/if/flow/default-authentication-flow/?next=/application/saml/aws/sso/binding/init/")
Expand Down Expand Up @@ -1078,6 +1168,144 @@ func Test_simplifiedFlowAuthWithSeperatedUsernamePasswordAndAuthenticator(t *tes
assert.Equal(result, samlResponse)
}

// Test_authWithWebAuthn drives the authenticator-validate stage with a webauthn
// device and no MFA token, expecting the provider to sign a FIDO assertion.
func Test_authWithWebAuthn(t *testing.T) {
defer gock.Off()
samlResponse := "PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46b2FzaX"
gock.New("http://127.0.0.1").
Get("/application/saml/aws/sso/binding/init").
Reply(302).
SetHeader("Set-Cookie", "[authentik_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJ6cHI3NGdzMjNnOGNqbmF1bXNheGQ1dXVrc2VtZGZpNyIsImlzcyI6ImF1dGhlbnRpayIsInN1YiI6ImFub255bW91cyIsImF1dGhlbnRpY2F0ZWQiOmZhbHNlLCJhY3IiOiJnb2F1dGhlbnRpay5pby9jb3JlL2RlZmF1bHQifQ.zNiX4pk6G9ABeDip0PLs8-0irm2aQ_Arr_RgTxTGCQM; HttpOnly; Path=/; SameSite=None; Secure]").
SetHeader("Location", "/flows/-/default/authentication/?next=/application/saml/aws/sso/binding/init/")

gock.New("http://127.0.0.1").
Get("/flows/-/default/authentication").
Reply(302).
SetHeader("Location", "/if/flow/default-authentication-flow/?next=%2Fapplication%2Fsaml%2Faws%2Fsso%2Fbinding%2Finit%2F")

gock.New("http://127.0.0.1").
Get("/if/flow/default-authentication-flow").
Reply(200).
BodyString("")

gock.New("http://127.0.0.1").
Get("/api/v3/flows/executor/default-authentication-flow").
Reply(200).
JSON(map[string]interface{}{
"type": "native",
"flow_info": map[string]interface{}{"title": "Welcome to authentik!", "background": "/static/dist/assets/images/flow_background.jpg", "cancel_url": "/flows/-/cancel/", "layout": "stacked"},
"component": "ak-stage-identification",
"user_fields": []string{"username", "email"},
"password_fields": false,
})

gock.New("http://127.0.0.1").
Post("/api/v3/flows/executor/default-authentication-flow").
Reply(302).
SetHeader("Location", "/api/v3/flows/executor/default-authentication-flow/?query=next%3D%252F")

gock.New("http://127.0.0.1").
Get("api/v3/flows/executor/default-authentication-flow").
Reply(200).
JSON(map[string]interface{}{
"type": "native",
"flow_info": map[string]interface{}{"title": "Welcome to authentik!", "background": "/static/dist/assets/images/flow_background.jpg", "cancel_url": "/flows/-/cancel/", "layout": "stacked"},
"component": "ak-stage-password",
"pending_user": "user",
})

gock.New("http://127.0.0.1").
Post("/api/v3/flows/executor/default-authentication-flow").
Reply(302).
SetHeader("Location", "/api/v3/flows/executor/default-authentication-flow/?query=next%3D%252F")

gock.New("http://127.0.0.1").
Get("api/v3/flows/executor/default-authentication-flow").
Reply(200).
JSON(map[string]interface{}{
"type": "native",
"flow_info": map[string]interface{}{"title": "Welcome to authentik!", "background": "/static/dist/assets/images/flow_background.jpg", "cancel_url": "/flows/-/cancel/", "layout": "stacked"},
"component": "ak-stage-authenticator-validate",
"pending_user": "user",
"device_challenges": []map[string]interface{}{
{
"device_class": "webauthn",
"device_uid": "13",
"challenge": map[string]interface{}{
"challenge": "Y2hhbGxlbmdl",
"rpId": "127.0.0.1",
"timeout": 60000,
"userVerification": "preferred",
"allowCredentials": []map[string]interface{}{
{"id": "cred-id", "type": "public-key", "transports": []string{"usb"}},
},
},
},
},
"configuration_stages": []any{},
})

gock.New("http://127.0.0.1").
Post("/api/v3/flows/executor/default-authentication-flow").
Reply(302).
SetHeader("Location", "/api/v3/flows/executor/default-authentication-flow/?query=next%3D%252F")

gock.New("http://127.0.0.1").
Get("/api/v3/flows/executor/default-authentication-flow").
Reply(302).
SetHeader("Location", "/api/v3/flows/executor/default-authentication-flow/?query=next%3D%252F")

gock.New("http://127.0.0.1").
Get("/api/v3/flows/executor/default-authentication-flow").
Reply(200).
JSON(map[string]interface{}{
"type": "redirect",
"to": "http://127.0.0.1/application/saml/aws/sso/binding/init",
})

gock.New("http://127.0.0.1").
Get("/application/saml/aws/sso/binding/init").
Reply(302).
SetHeader("Location", "/if/flow/default-provider-authorization-implicit-consent/")

gock.New("http://127.0.0.1").
Get("/if/flow/default-provider-authorization-implicit-consent/").
Reply(200)

gock.New("http://127.0.0.1").
Get("/api/v3/flows").
Reply(200).
JSON(map[string]interface{}{
"type": "native",
"flow_info": map[string]interface{}{"title": "Redirecting to aws", "background": "/static/dist/assets/images/flow_background.jpg", "cancel_url": "/flows/-/cancel/", "layout": "stacked"},
"component": "ak-stage-autosubmit",
"url": "https://signin.amazonaws.com/saml",
"attrs": map[string]interface{}{
"ACSUrl": "https://signin.amazonaws.com/saml",
"SAMLResponse": samlResponse,
},
})

client, _ := New(&cfg.IDPAccount{})
client.fido = &fakeFido{assertion: &okta.SignedAssertion{
ClientData: "client-data-base64url",
SignatureData: base64.StdEncoding.EncodeToString([]byte("signature")),
AuthenticatorData: base64.StdEncoding.EncodeToString([]byte("authdata")),
}}
loginDetails := &creds.LoginDetails{
Username: "user",
Password: "pwd",
URL: "http://127.0.0.1/application/saml/aws/sso/binding/init",
}
gock.InterceptClient(&client.client.Client)
result, err := client.Authenticate(loginDetails)

assert := assert.New(t)
assert.Nil(err)
assert.Equal(result, samlResponse)
}

// Test_authWithCombinedUsernamePasswordAndAuthenticator Password only if username/email verified
func Test_authWithCombinedUsernamePasswordAndAuthenticator(t *testing.T) {
defer gock.Off()
Expand Down
Loading