diff --git a/pkg/provider/authentik/authentik.go b/pkg/provider/authentik/authentik.go index de6d828aa..c679b16c0 100644 --- a/pkg/provider/authentik/authentik.go +++ b/pkg/provider/authentik/authentik.go @@ -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") @@ -40,6 +43,7 @@ func New(idpAccount *cfg.IDPAccount) (*Client, error) { } return &Client{ client: client, + fido: u2fAuthenticator{}, }, nil } @@ -47,6 +51,7 @@ func New(idpAccount *cfg.IDPAccount) (*Client, error) { func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) { ctx := &authentikContext{ loginDetails: loginDetails, + url: loginDetails.URL, } samlResponse, err := kc.auth(ctx) if err != nil { @@ -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") } @@ -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 } @@ -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 } @@ -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 } @@ -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 diff --git a/pkg/provider/authentik/authentik_test.go b/pkg/provider/authentik/authentik_test.go index bcc0e12b7..a7f4067f3 100644 --- a/pkg/provider/authentik/authentik_test.go +++ b/pkg/provider/authentik/authentik_test.go @@ -1,6 +1,8 @@ package authentik import ( + "encoding/base64" + "encoding/json" "testing" "github.com/h2non/gock" @@ -8,8 +10,27 @@ import ( "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{ @@ -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/") @@ -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() diff --git a/pkg/provider/authentik/model.go b/pkg/provider/authentik/model.go index 3163052b0..8cb63b6db 100644 --- a/pkg/provider/authentik/model.go +++ b/pkg/provider/authentik/model.go @@ -1,6 +1,7 @@ package authentik import ( + "encoding/json" "fmt" "net/url" "strings" @@ -12,6 +13,10 @@ import ( type authentikContext struct { loginDetails *creds.LoginDetails + // url is the current position in the authentik flow. It is kept separate + // from loginDetails.URL so that the original entry URL is preserved for the + // caller (e.g. credential storage and the SAML cache key). + url string samlResponse string } @@ -22,18 +27,61 @@ type authentikPayload struct { HasPasswordField bool `json:"password_fields"` RedirectTo string `json:"to"` Errors map[string][]map[string]string `json:"response_errors"` + DeviceChallenges []deviceChallenge `json:"device_challenges"` +} + +// deviceChallenge is a single entry of the authenticator-validate stage's +// device_challenges array. The challenge payload is device-class specific, so it +// is kept raw until the device class is known. +type deviceChallenge struct { + DeviceClass string `json:"device_class"` + DeviceUID string `json:"device_uid"` + Challenge json.RawMessage `json:"challenge"` +} + +// webAuthnRequestOptions mirrors the PublicKeyCredentialRequestOptions that +// authentik embeds in a webauthn device challenge. All binary values are +// base64url encoded. +type webAuthnRequestOptions struct { + Challenge string `json:"challenge"` + RpID string `json:"rpId"` + Timeout int `json:"timeout"` + UserVerification string `json:"userVerification"` + AllowCredentials []webAuthnCredentialDescriptor `json:"allowCredentials"` +} + +type webAuthnCredentialDescriptor struct { + ID string `json:"id"` + Type string `json:"type"` + Transports []string `json:"transports"` +} + +// webAuthnChallenge returns the parsed webauthn request options if the payload +// offers a webauthn device, or nil when none is present. +func (payload *authentikPayload) webAuthnChallenge() (*webAuthnRequestOptions, error) { + for _, dc := range payload.DeviceChallenges { + if dc.DeviceClass != "webauthn" { + continue + } + var opts webAuthnRequestOptions + if err := json.Unmarshal(dc.Challenge, &opts); err != nil { + return nil, errors.Wrap(err, "error parsing webauthn challenge") + } + return &opts, nil + } + return nil, nil } func (ctx *authentikContext) updateURL(s string) error { if strings.Index(s, "/") == 0 { - u, err := url.Parse(ctx.loginDetails.URL) + u, err := url.Parse(ctx.url) if err != nil { return errors.New("Invalid url") } s = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, s) } - ctx.loginDetails.URL = s + ctx.url = s return nil } diff --git a/pkg/provider/authentik/model_test.go b/pkg/provider/authentik/model_test.go index 62df451fd..a06e0adf2 100644 --- a/pkg/provider/authentik/model_test.go +++ b/pkg/provider/authentik/model_test.go @@ -9,18 +9,24 @@ import ( func Test_updateURL(t *testing.T) { assert := assert.New(t) + loginDetails := &creds.LoginDetails{ + Username: "user", + Password: "pwd", + URL: "https://127.0.0.1/sso/init", + } ctx := &authentikContext{ - loginDetails: &creds.LoginDetails{ - Username: "user", - Password: "pwd", - URL: "https://127.0.0.1/sso/init", - }, + loginDetails: loginDetails, + url: loginDetails.URL, } err := ctx.updateURL("/query?next=/login") assert.Nil(err) - assert.Equal(ctx.loginDetails.URL, "https://127.0.0.1/query?next=/login") + assert.Equal(ctx.url, "https://127.0.0.1/query?next=/login") err = ctx.updateURL("https://127.0.0.1:8888/sso/aws") assert.Nil(err) - assert.Equal(ctx.loginDetails.URL, "https://127.0.0.1:8888/sso/aws") + assert.Equal(ctx.url, "https://127.0.0.1:8888/sso/aws") + + // The original entry URL must be preserved for the caller (credential + // storage, SAML cache key). + assert.Equal(loginDetails.URL, "https://127.0.0.1/sso/init") } diff --git a/pkg/provider/authentik/webauthn.go b/pkg/provider/authentik/webauthn.go new file mode 100644 index 000000000..04e7b89ed --- /dev/null +++ b/pkg/provider/authentik/webauthn.go @@ -0,0 +1,118 @@ +package authentik + +import ( + "encoding/base64" + "encoding/json" + + "github.com/marshallbrekka/go-u2fhost" + "github.com/pkg/errors" + + "github.com/versent/saml2aws/v2/pkg/provider/okta" +) + +// fidoAuthenticator performs a single WebAuthn (FIDO U2F) assertion against a +// hardware security key. It is abstracted behind an interface so that tests can +// inject a fake device without touching real USB hardware. +type fidoAuthenticator interface { + challengeU2F(challenge, rpID, keyHandle string) (*okta.SignedAssertion, error) +} + +// u2fAuthenticator is the production fidoAuthenticator. It reuses the shared FIDO +// client that the Okta and Keycloak providers rely on. +type u2fAuthenticator struct{} + +func (u2fAuthenticator) challengeU2F(challenge, rpID, keyHandle string) (*okta.SignedAssertion, error) { + // AppID == rpID makes the underlying client build the WebAuthn origin as + // "https://"+rpID, which is exactly what authentik expects. + fido, err := okta.NewFidoClient(challenge, rpID, "", keyHandle, "", new(okta.U2FDeviceFinder)) + if err != nil { + return nil, err + } + return fido.ChallengeU2F() +} + +// webAuthnAssertion is the body posted back to the authenticator-validate stage +// when answering with a security key. It mirrors the PublicKeyCredential the +// authentik web frontend submits. +type webAuthnAssertion struct { + Component string `json:"component"` + WebAuthn webAuthnCredential `json:"webauthn"` +} + +type webAuthnCredential struct { + ID string `json:"id"` + RawID string `json:"rawId"` + Type string `json:"type"` + Response webAuthnCredentialResponse `json:"response"` +} + +type webAuthnCredentialResponse struct { + ClientDataJSON string `json:"clientDataJSON"` + AuthenticatorData string `json:"authenticatorData"` + Signature string `json:"signature"` + UserHandle *string `json:"userHandle"` +} + +// signWebAuthnAssertion drives the hardware key for the given webauthn challenge +// and marshals the assertion into the POST body authentik expects. +func (kc *Client) signWebAuthnAssertion(component string, opts *webAuthnRequestOptions) ([]byte, error) { + if len(opts.AllowCredentials) == 0 { + return nil, errors.New("authentik webauthn challenge contains no credentials; " + + "passwordless/resident keys are not supported by the U2F flow") + } + + var assertion *okta.SignedAssertion + var pickedID string + for i, cred := range opts.AllowCredentials { + a, err := kc.fido.challengeU2F(opts.Challenge, opts.RpID, cred.ID) + if _, ok := err.(*u2fhost.BadKeyHandleError); ok && i < len(opts.AllowCredentials)-1 { + logger.Debug("webauthn device does not have this key handle, trying next") + continue + } + if err != nil { + return nil, errors.Wrap(err, "error performing webauthn assertion") + } + assertion = a + pickedID = cred.ID + break + } + if assertion == nil { + return nil, errors.New("no registered webauthn device was recognized") + } + + // The shared FIDO client returns clientDataJSON already base64url encoded, but + // authenticatorData and signature in standard base64; authentik wants base64url. + authenticatorData, err := urlEncode(assertion.AuthenticatorData) + if err != nil { + return nil, errors.Wrap(err, "unexpected format for webauthn authenticator data") + } + signature, err := urlEncode(assertion.SignatureData) + if err != nil { + return nil, errors.Wrap(err, "unexpected format for webauthn signature data") + } + + body := webAuthnAssertion{ + Component: component, + WebAuthn: webAuthnCredential{ + ID: pickedID, + RawID: pickedID, + Type: "public-key", + Response: webAuthnCredentialResponse{ + ClientDataJSON: assertion.ClientData, + AuthenticatorData: authenticatorData, + Signature: signature, + UserHandle: nil, + }, + }, + } + return json.Marshal(body) +} + +// urlEncode re-encodes a standard base64 string as base64url without padding. +func urlEncode(stdEncodedStr string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(stdEncodedStr) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(decoded), nil +}