logout: add support of logout flow
This commit is contained in:
332
internal/identp/identp.go
Normal file
332
internal/identp/identp.go
Normal file
@ -0,0 +1,332 @@
|
||||
/*
|
||||
Copyright (C) JSC iCore - All Rights Reserved
|
||||
|
||||
Unauthorized copying of this file, via any medium is strictly prohibited
|
||||
Proprietary and confidential
|
||||
*/
|
||||
|
||||
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
|
||||
// between ORY Hydra and Werther Identity Provider.
|
||||
package identp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.i-core.ru/logutil"
|
||||
"gopkg.i-core.ru/werther/internal/hydra"
|
||||
)
|
||||
|
||||
const loginTmplName = "login.tmpl"
|
||||
|
||||
// Config is a Hydra configuration.
|
||||
type Config struct {
|
||||
HydraURL string `envconfig:"hydra_url" required:"true" desc:"a server admin URL of ORY Hydra"`
|
||||
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
|
||||
ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Fi-core.ru%2Fclaims%2Froles:roles" desc:"a mapping of OIDC claims to scopes (all claims are URL encoded)"`
|
||||
}
|
||||
|
||||
// UserManager is an interface that is used for authentication and providing user's claims.
|
||||
type UserManager interface {
|
||||
authenticator
|
||||
oidcClaimsFinder
|
||||
}
|
||||
|
||||
// authenticator is an interface that is used for a user authentication.
|
||||
//
|
||||
// Authenticate returns false if the username or password is invalid.
|
||||
type authenticator interface {
|
||||
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
|
||||
}
|
||||
|
||||
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
|
||||
type oidcClaimsFinder interface {
|
||||
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
// TemplateRenderer renders a template with data and writes it to a http.ResponseWriter.
|
||||
type TemplateRenderer interface {
|
||||
RenderTemplate(w http.ResponseWriter, name string, data interface{}) error
|
||||
}
|
||||
|
||||
// LoginTmplData is a data that is needed for rendering the login page.
|
||||
type LoginTmplData struct {
|
||||
CSRFToken string
|
||||
Challenge string
|
||||
LoginURL string
|
||||
IsInvalidCredentials bool
|
||||
IsInternalError bool
|
||||
}
|
||||
|
||||
// Handler provides HTTP handlers that implement [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
|
||||
// between ORY Hydra and Werther Identity Provider.
|
||||
type Handler struct {
|
||||
Config
|
||||
um UserManager
|
||||
tr TemplateRenderer
|
||||
}
|
||||
|
||||
// NewHandler creates a new Handler.
|
||||
//
|
||||
// The template's renderer must be able to render a template with name "login.tmpl".
|
||||
// The template is a template of the login page. It receives struct LoginTmplData as template's data.
|
||||
func NewHandler(cnf Config, um UserManager, tr TemplateRenderer) *Handler {
|
||||
return &Handler{Config: cnf, um: um, tr: tr}
|
||||
}
|
||||
|
||||
// AddRoutes registers all required routes for Login & Consent Provider.
|
||||
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
|
||||
sessionTTL := int(h.SessionTTL.Seconds())
|
||||
apply(http.MethodGet, "/login", newLoginStartHandler(hydra.NewLoginReqDoer(h.HydraURL, 0), h.tr))
|
||||
apply(http.MethodPost, "/login", newLoginEndHandler(hydra.NewLoginReqDoer(h.HydraURL, sessionTTL), h.um, h.tr))
|
||||
apply(http.MethodGet, "/consent", newConsentHandler(hydra.NewConsentReqDoer(h.HydraURL, sessionTTL), h.um, h.ClaimScopes))
|
||||
apply(http.MethodGet, "/logout", newLogoutHandler(hydra.NewLogoutReqDoer(h.HydraURL)))
|
||||
}
|
||||
|
||||
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
|
||||
type oa2LoginReqAcceptor interface {
|
||||
AcceptLoginRequest(challenge string, remember bool, subject string) (string, error)
|
||||
}
|
||||
|
||||
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
|
||||
//
|
||||
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
|
||||
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
|
||||
type oa2LoginReqProcessor interface {
|
||||
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
|
||||
oa2LoginReqAcceptor
|
||||
}
|
||||
|
||||
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logutil.FromContext(r.Context()).Sugar()
|
||||
challenge := r.URL.Query().Get("login_challenge")
|
||||
if challenge == "" {
|
||||
log.Debug("No login challenge that is needed by the OAuth2 provider")
|
||||
http.Error(w, "No login challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ri, err := rproc.InitiateRequest(challenge)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case hydra.ErrChallengeNotFound:
|
||||
log.Debugw("Unknown login challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
|
||||
return
|
||||
case hydra.ErrChallengeExpired:
|
||||
log.Debugw("Login challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Infow("Failed to initiate an OAuth2 login request", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
|
||||
|
||||
if ri.Skip {
|
||||
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, ri.Subject)
|
||||
if err != nil {
|
||||
log.Infow("Failed to accept an OAuth login request", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, redirectURI, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := LoginTmplData{
|
||||
CSRFToken: nosurf.Token(r),
|
||||
Challenge: challenge,
|
||||
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
|
||||
}
|
||||
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
|
||||
log.Infow("Failed to render a login page template", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logutil.FromContext(r.Context()).Sugar()
|
||||
r.ParseForm()
|
||||
|
||||
challenge := r.Form.Get("login_challenge")
|
||||
if challenge == "" {
|
||||
log.Debug("No login challenge that is needed by the OAuth2 provider")
|
||||
http.Error(w, "No login challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data := LoginTmplData{
|
||||
CSRFToken: nosurf.Token(r),
|
||||
Challenge: challenge,
|
||||
LoginURL: r.URL.String(),
|
||||
}
|
||||
|
||||
username, password := r.Form.Get("username"), r.Form.Get("password")
|
||||
|
||||
switch ok, err := auther.Authenticate(r.Context(), username, password); {
|
||||
case err != nil:
|
||||
data.IsInternalError = true
|
||||
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
|
||||
zap.Error(err), "challenge", challenge, "username", username)
|
||||
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
|
||||
log.Infow("Failed to render a login page template", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
case !ok:
|
||||
data.IsInvalidCredentials = true
|
||||
log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username)
|
||||
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
|
||||
log.Infow("Failed to render a login page template", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
|
||||
|
||||
remember := r.Form.Get("remember") != ""
|
||||
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, username)
|
||||
if err != nil {
|
||||
data.IsInternalError = true
|
||||
log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err))
|
||||
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
|
||||
log.Infow("Failed to render a login page template", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
|
||||
//
|
||||
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
|
||||
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
|
||||
type oa2ConsentReqProcessor interface {
|
||||
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
|
||||
AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error)
|
||||
}
|
||||
|
||||
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logutil.FromContext(r.Context()).Sugar()
|
||||
|
||||
challenge := r.URL.Query().Get("consent_challenge")
|
||||
if challenge == "" {
|
||||
log.Debug("No consent challenge that is needed by the OAuth2 provider")
|
||||
http.Error(w, "No consent challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ri, err := rproc.InitiateRequest(challenge)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case hydra.ErrChallengeNotFound:
|
||||
log.Debugw("Unknown consent challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
|
||||
return
|
||||
case hydra.ErrChallengeExpired:
|
||||
log.Debugw("Consent challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Infow("Failed to send an OAuth2 consent request", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
|
||||
|
||||
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
|
||||
if err != nil {
|
||||
log.Infow("Failed to find user's OIDC claims", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debugw("Found user's OIDC claims", "claims", claims)
|
||||
|
||||
// Remove claims that are not in the requested scopes.
|
||||
for claim := range claims {
|
||||
var found bool
|
||||
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
|
||||
// It is because of config option's format compatibility.
|
||||
if scope, ok := claimScopes[url.QueryEscape(claim)]; ok {
|
||||
for _, rscope := range ri.RequestedScopes {
|
||||
if rscope == scope {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
delete(claims, claim)
|
||||
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
|
||||
}
|
||||
}
|
||||
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, ri.RequestedScopes, claims)
|
||||
if err != nil {
|
||||
log.Infow("Failed to accept a consent request to the OAuth2 provider", zap.Error(err), "scopes", ri.RequestedScopes, "claims", claims)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
|
||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
// oa2LogoutReqProcessor is an interface that is used for creating and accepting an OAuth2 logout request.
|
||||
//
|
||||
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
|
||||
type oa2LogoutReqProcessor interface {
|
||||
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
|
||||
AcceptLogoutRequest(challenge string) (string, error)
|
||||
}
|
||||
|
||||
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logutil.FromContext(r.Context()).Sugar()
|
||||
|
||||
challenge := r.URL.Query().Get("logout_challenge")
|
||||
if challenge == "" {
|
||||
log.Debug("No logout challenge that is needed by the OAuth2 provider")
|
||||
http.Error(w, "No logout challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ri, err := rproc.InitiateRequest(challenge)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case hydra.ErrChallengeNotFound:
|
||||
log.Debugw("Unknown logout challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, "Unknown logout challenge", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Infow("Failed to send an OAuth2 logout request", zap.Error(err), "challenge", challenge)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Infow("A logout request is initiated", "challenge", challenge, "username", ri.Subject)
|
||||
|
||||
redirectTo, err := rproc.AcceptLogoutRequest(challenge)
|
||||
if err != nil {
|
||||
log.Infow("Failed to accept the logout request to the OAuth2 provider", zap.Error(err))
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Debugw("Accepted the logout request to the OAuth2 provider")
|
||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||
}
|
||||
}
|
568
internal/identp/identp_test.go
Normal file
568
internal/identp/identp_test.go
Normal file
@ -0,0 +1,568 @@
|
||||
/*
|
||||
Copyright (C) JSC iCore - All Rights Reserved
|
||||
|
||||
Unauthorized copying of this file, via any medium is strictly prohibited
|
||||
Proprietary and confidential
|
||||
*/
|
||||
|
||||
package identp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.i-core.ru/werther/internal/hydra"
|
||||
)
|
||||
|
||||
func TestHandleLoginStart(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge string
|
||||
scopes []string
|
||||
skip bool
|
||||
subject string
|
||||
redirect string
|
||||
wantStatus int
|
||||
wantInitErr error
|
||||
wantAcceptErr error
|
||||
wantLoc string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "no login challenge",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "happy path",
|
||||
challenge: "foo",
|
||||
wantStatus: http.StatusOK,
|
||||
wantBody: `
|
||||
LoginURL: login?login_challenge=foo;
|
||||
CSRFToken: true;
|
||||
Challenge: foo;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "skip",
|
||||
challenge: "foo",
|
||||
skip: true,
|
||||
wantLoc: "/",
|
||||
wantStatus: http.StatusFound,
|
||||
},
|
||||
{
|
||||
name: "unknown challenge",
|
||||
challenge: "foo",
|
||||
wantInitErr: hydra.ErrChallengeNotFound,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "used challenge",
|
||||
challenge: "foo",
|
||||
wantInitErr: hydra.ErrChallengeExpired,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "init error",
|
||||
challenge: "foo",
|
||||
wantInitErr: errors.New("init error"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "accept error",
|
||||
challenge: "foo",
|
||||
skip: true,
|
||||
wantAcceptErr: errors.New("accept error"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
url := "/login"
|
||||
if tc.challenge != "" {
|
||||
url += "?login_challenge=" + tc.challenge
|
||||
}
|
||||
r, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Host = "gopkg.example.org"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
tmplRenderer := &testTemplateRenderer{
|
||||
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
|
||||
if name != "login.tmpl" {
|
||||
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
|
||||
}
|
||||
|
||||
const loginT = `
|
||||
LoginURL: {{ .LoginURL }};
|
||||
CSRFToken: {{ if .CSRFToken }} true {{- else -}} false {{- end }};
|
||||
Challenge: {{ .Challenge }};
|
||||
`
|
||||
tmpl, err := template.New("login").Parse(loginT)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse template: %s", err)
|
||||
}
|
||||
return tmpl.Execute(w, data)
|
||||
},
|
||||
}
|
||||
rproc := testLoginReqProc{
|
||||
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
return &hydra.ReqInfo{
|
||||
Challenge: tc.challenge,
|
||||
RequestedScopes: tc.scopes,
|
||||
Skip: tc.skip,
|
||||
Subject: tc.subject,
|
||||
}, tc.wantInitErr
|
||||
},
|
||||
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
if remember {
|
||||
t.Error("unexpected enabled remember flag")
|
||||
}
|
||||
if subject != tc.subject {
|
||||
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
|
||||
}
|
||||
return tc.redirect, tc.wantAcceptErr
|
||||
},
|
||||
}
|
||||
handler := nosurf.New(newLoginStartHandler(rproc, tmplRenderer))
|
||||
handler.ExemptPath("/login")
|
||||
handler.ServeHTTP(rr, r)
|
||||
|
||||
if status := rr.Code; status != tc.wantStatus {
|
||||
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
|
||||
}
|
||||
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
|
||||
if wantBody != "" && gotBody != wantBody {
|
||||
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
|
||||
}
|
||||
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
|
||||
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func noindent(s string) string {
|
||||
wsRe := regexp.MustCompile(`(?:^\s+|(;)\s+)`)
|
||||
return wsRe.ReplaceAllString(s, "$1 ")
|
||||
}
|
||||
|
||||
type testLoginReqProc struct {
|
||||
initReqFunc func(string) (*hydra.ReqInfo, error)
|
||||
acceptReqFunc func(string, bool, string) (string, error)
|
||||
}
|
||||
|
||||
func (lrp testLoginReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
|
||||
return lrp.initReqFunc(challenge)
|
||||
}
|
||||
|
||||
func (lrp testLoginReqProc) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) {
|
||||
return lrp.acceptReqFunc(challenge, remember, subject)
|
||||
}
|
||||
|
||||
func TestHandleLoginEnd(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge string
|
||||
subject string
|
||||
webBasePath string
|
||||
redirect string
|
||||
wantStatus int
|
||||
wantAcceptErr error
|
||||
wantAuthErr error
|
||||
wantInvAuth bool
|
||||
wantLoc string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "no login challenge",
|
||||
subject: "joe",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "happy path",
|
||||
challenge: "foo",
|
||||
subject: "joe",
|
||||
redirect: "/redirect-to",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/redirect-to",
|
||||
},
|
||||
{
|
||||
name: "auth unknown error",
|
||||
challenge: "foo",
|
||||
subject: "joe",
|
||||
webBasePath: "testBasePath",
|
||||
wantStatus: http.StatusOK,
|
||||
wantInvAuth: false,
|
||||
wantAuthErr: errors.New("Unknown error"),
|
||||
wantBody: `
|
||||
LoginURL: /login;
|
||||
CSRFToken: T;
|
||||
Challenge: foo;
|
||||
InvCreds: F;
|
||||
IsIntErr: T;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "unauth error",
|
||||
challenge: "foo",
|
||||
subject: "joe",
|
||||
wantStatus: http.StatusOK,
|
||||
wantInvAuth: true,
|
||||
wantBody: `
|
||||
LoginURL: /login;
|
||||
CSRFToken: T;
|
||||
Challenge: foo;
|
||||
InvCreds: T;
|
||||
IsIntErr: F;
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "accept error",
|
||||
challenge: "foo",
|
||||
subject: "joe",
|
||||
wantStatus: http.StatusOK,
|
||||
wantAcceptErr: errors.New("accept error"),
|
||||
wantBody: `
|
||||
LoginURL: /login;
|
||||
CSRFToken: T;
|
||||
Challenge: foo;
|
||||
InvCreds: F;
|
||||
IsIntErr: T;
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
url := "/login"
|
||||
ps := "username=joe&password=pass"
|
||||
if tc.challenge != "" {
|
||||
ps += "&login_challenge=" + tc.challenge
|
||||
}
|
||||
r, err := http.NewRequest("POST", url, strings.NewReader(ps))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Host = "gopkg.example.org"
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
tmplRenderer := &testTemplateRenderer{
|
||||
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
|
||||
if name != "login.tmpl" {
|
||||
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
|
||||
}
|
||||
|
||||
const loginT = `
|
||||
LoginURL: {{ .LoginURL }};
|
||||
CSRFToken: {{ if .CSRFToken -}} T {{- else -}} F {{- end }};
|
||||
Challenge: {{ .Challenge }};
|
||||
InvCreds: {{ if .IsInvalidCredentials -}} T {{- else -}} F {{- end }};
|
||||
IsIntErr: {{ if .IsInternalError -}} T {{- else -}} F {{- end}};
|
||||
`
|
||||
tmpl, err := template.New("login").Parse(loginT)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse template: %s", err)
|
||||
}
|
||||
return tmpl.Execute(w, data)
|
||||
},
|
||||
}
|
||||
rproc := testLoginReqProc{
|
||||
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
if remember {
|
||||
t.Error("unexpected enabled remember flag")
|
||||
}
|
||||
if subject != tc.subject {
|
||||
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
|
||||
}
|
||||
return tc.redirect, tc.wantAcceptErr
|
||||
},
|
||||
}
|
||||
auther := testAuthenticator{
|
||||
authnFunc: func(ctx context.Context, username, password string) (bool, error) {
|
||||
if username == "" {
|
||||
t.Error("unexpected empty username")
|
||||
}
|
||||
if password == "" {
|
||||
t.Error("unexpected empty password")
|
||||
}
|
||||
return !tc.wantInvAuth, tc.wantAuthErr
|
||||
},
|
||||
}
|
||||
handler := nosurf.New(newLoginEndHandler(rproc, auther, tmplRenderer))
|
||||
handler.ExemptPath("/login")
|
||||
handler.ServeHTTP(rr, r)
|
||||
|
||||
if status := rr.Code; status != tc.wantStatus {
|
||||
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
|
||||
}
|
||||
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
|
||||
if wantBody != "" && gotBody != wantBody {
|
||||
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
|
||||
}
|
||||
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
|
||||
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testTemplateRenderer struct {
|
||||
renderTmplFunc func(w http.ResponseWriter, name string, data interface{}) error
|
||||
}
|
||||
|
||||
func (tl *testTemplateRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
|
||||
return tl.renderTmplFunc(w, name, data)
|
||||
}
|
||||
|
||||
type testAuthenticator struct {
|
||||
authnFunc func(context.Context, string, string) (bool, error)
|
||||
}
|
||||
|
||||
func (au testAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
|
||||
return au.authnFunc(ctx, username, password)
|
||||
}
|
||||
|
||||
func TestHandleConsent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge string
|
||||
redirect string
|
||||
subject string
|
||||
skip bool
|
||||
claims map[string]interface{}
|
||||
scopes []string
|
||||
wantStatus int
|
||||
wantAcceptErr error
|
||||
wantInitErr error
|
||||
wantFindErr error
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "no login challenge",
|
||||
subject: "joe",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown challenge",
|
||||
challenge: "foo",
|
||||
wantInitErr: hydra.ErrChallengeNotFound,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "used challenge",
|
||||
challenge: "foo",
|
||||
wantInitErr: hydra.ErrChallengeExpired,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "happy path",
|
||||
challenge: "foo",
|
||||
subject: "joe",
|
||||
redirect: "/redirect-to",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/redirect-to",
|
||||
claims: map[string]interface{}{"a": "foo", "b": "bar", "c": "baz"},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
url := "/consent"
|
||||
if tc.challenge != "" {
|
||||
url += "?consent_challenge=" + tc.challenge
|
||||
}
|
||||
r, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Host = "gopkg.example.org"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
rproc := testConsentReqProc{
|
||||
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
return &hydra.ReqInfo{
|
||||
Challenge: tc.challenge,
|
||||
Subject: tc.subject,
|
||||
RequestedScopes: tc.scopes,
|
||||
}, tc.wantInitErr
|
||||
},
|
||||
acceptReqFunc: func(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
if remember == tc.skip {
|
||||
t.Error("unexpected enabled remember flag")
|
||||
}
|
||||
if len(grantScope) != len(tc.scopes) {
|
||||
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
|
||||
} else {
|
||||
for i := range grantScope {
|
||||
if grantScope[i] != tc.scopes[i] {
|
||||
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(idToken, tc.claims) {
|
||||
t.Errorf("wrong an id token while accepting the request: got %q; want %q", idToken, tc.claims)
|
||||
}
|
||||
return tc.redirect, tc.wantAcceptErr
|
||||
},
|
||||
}
|
||||
cfinder := testOIDCClaimsFinder{
|
||||
findFunc: func(ctx context.Context, username string) (map[string]interface{}, error) {
|
||||
if username == "" {
|
||||
t.Error("unexpected empty username")
|
||||
}
|
||||
return tc.claims, tc.wantFindErr
|
||||
},
|
||||
}
|
||||
handler := nosurf.New(newConsentHandler(rproc, cfinder, nil))
|
||||
handler.ExemptPath("/consent")
|
||||
handler.ServeHTTP(rr, r)
|
||||
|
||||
if status := rr.Code; status != tc.wantStatus {
|
||||
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
|
||||
}
|
||||
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
|
||||
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testConsentReqProc struct {
|
||||
initReqFunc func(string) (*hydra.ReqInfo, error)
|
||||
acceptReqFunc func(string, bool, []string, interface{}) (string, error)
|
||||
}
|
||||
|
||||
func (crp testConsentReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
|
||||
return crp.initReqFunc(challenge)
|
||||
}
|
||||
|
||||
func (crp testConsentReqProc) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
|
||||
return crp.acceptReqFunc(challenge, remember, grantScope, idToken)
|
||||
}
|
||||
|
||||
type testOIDCClaimsFinder struct {
|
||||
findFunc func(context.Context, string) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
func (cf testOIDCClaimsFinder) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
|
||||
return cf.findFunc(ctx, username)
|
||||
}
|
||||
|
||||
func TestLogoutHandler(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge string
|
||||
initErr error
|
||||
acceptErr error
|
||||
redirectTo string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "no login challenge",
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown challenge",
|
||||
challenge: "foo",
|
||||
initErr: hydra.ErrChallengeNotFound,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "init logout request error",
|
||||
challenge: "foo",
|
||||
initErr: fmt.Errorf("init logout request error"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "accept logout request error",
|
||||
challenge: "foo",
|
||||
acceptErr: fmt.Errorf("accept logout request error"),
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "happy path",
|
||||
challenge: "foo",
|
||||
redirectTo: "/redirect-to",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/redirect-to",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
url := "/logout"
|
||||
if tc.challenge != "" {
|
||||
url += "?logout_challenge=" + tc.challenge
|
||||
}
|
||||
r, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Host = "gopkg.example.org"
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
rproc := &testLogoutReqProc{
|
||||
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
return &hydra.ReqInfo{}, tc.initErr
|
||||
},
|
||||
acceptReqFunc: func(challenge string) (string, error) {
|
||||
if challenge != tc.challenge {
|
||||
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
|
||||
}
|
||||
return tc.redirectTo, tc.acceptErr
|
||||
},
|
||||
}
|
||||
handler := newLogoutHandler(rproc)
|
||||
handler.ServeHTTP(rr, r)
|
||||
|
||||
if rr.Code != tc.wantStatus {
|
||||
t.Errorf("wrong status code: got %v; want %v", rr.Code, tc.wantStatus)
|
||||
}
|
||||
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
|
||||
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testLogoutReqProc struct {
|
||||
initReqFunc func(string) (*hydra.ReqInfo, error)
|
||||
acceptReqFunc func(string) (string, error)
|
||||
}
|
||||
|
||||
func (p *testLogoutReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
|
||||
return p.initReqFunc(challenge)
|
||||
}
|
||||
|
||||
func (p *testLogoutReqProc) AcceptLogoutRequest(challenge string) (string, error) {
|
||||
return p.acceptReqFunc(challenge)
|
||||
}
|
Reference in New Issue
Block a user