package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/coreos/go-oidc/v3/oidc"
)
const (
testRedirect = "http://127.0.0.1:53682/callback"
testVerifier = "a-sufficiently-long-pkce-code-verifier-0123456789-abcdefghij"
)
type testEnv struct {
issuer string
accounts *Accounts
client *http.Client
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
dir := t.TempDir()
accounts, err := LoadAccounts(filepath.Join(dir, "accounts.json"))
if err != nil {
t.Fatal(err)
}
if err := accounts.Create("alice", "correct horse"); err != nil {
t.Fatal(err)
}
var handler http.Handler
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
handler, err = newServer(srv.URL, dir, accounts, nil)
if err != nil {
t.Fatal(err)
}
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.URL.Host != strings.TrimPrefix(srv.URL, "http://") {
return http.ErrUseLastResponse
}
return nil
}}
return &testEnv{issuer: srv.URL, accounts: accounts, client: client}
}
func challenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func (e *testEnv) startAuth(t *testing.T, redirectURI string) (*http.Response, string) {
t.Helper()
q := url.Values{
"client_id": {"quple"},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"openid profile offline_access"},
"state": {"st4te"},
"nonce": {"n0nce"},
"code_challenge": {challenge(testVerifier)},
"code_challenge_method": {"S256"},
}
resp, err := e.client.Get(e.issuer + "/authorize?" + q.Encode())
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp, resp.Request.URL.Query().Get("authRequestID")
}
func (e *testEnv) signIn(t *testing.T, password string) (*http.Response, string) {
t.Helper()
_, id := e.startAuth(t, testRedirect)
if id == "" {
t.Fatal("authorize did not lead to the login page")
}
resp, err := e.client.PostForm(e.issuer+"/login", url.Values{
"authRequestID": {id},
"username": {"alice"},
"password": {password},
})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
if loc == nil || !strings.HasPrefix(loc.String(), testRedirect) {
return resp, ""
}
if loc.Query().Get("state") != "st4te" {
t.Fatalf("state not passed back: %s", loc)
}
return resp, loc.Query().Get("code")
}
type tokens struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
Error string `json:"error"`
}
func (e *testEnv) token(t *testing.T, form url.Values) (int, tokens) {
t.Helper()
form.Set("client_id", "quple")
resp, err := http.PostForm(e.issuer+"/oauth/token", form)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var tr tokens
_ = json.NewDecoder(resp.Body).Decode(&tr)
return resp.StatusCode, tr
}
func (e *testEnv) exchange(t *testing.T, code, verifier string) (int, tokens) {
return e.token(t, url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {testRedirect},
"code_verifier": {verifier},
})
}
func (e *testEnv) refresh(t *testing.T, refreshToken string) (int, tokens) {
return e.token(t, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refreshToken}})
}
func TestSignInAndVerifyWithGoOIDC(t *testing.T) {
e := newTestEnv(t)
_, code := e.signIn(t, "correct horse")
if code == "" {
t.Fatal("no code")
}
status, tr := e.exchange(t, code, testVerifier)
if status != http.StatusOK {
t.Fatalf("token exchange: %d %s", status, tr.Error)
}
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, e.issuer)
if err != nil {
t.Fatal(err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: "quple"})
idToken, err := verifier.Verify(ctx, tr.IDToken)
if err != nil {
t.Fatalf("id token: %v", err)
}
var idClaims struct {
Username string `json:"preferred_username"`
Nonce string `json:"nonce"`
}
_ = idToken.Claims(&idClaims)
if idToken.Subject != "alice" || idClaims.Username != "alice" || idClaims.Nonce != "n0nce" {
t.Fatalf("id token: sub %q, claims %+v", idToken.Subject, idClaims)
}
access, err := verifier.Verify(ctx, tr.AccessToken)
if err != nil {
t.Fatalf("access token: %v", err)
}
if access.Subject != "alice" {
t.Fatalf("access token subject %q", access.Subject)
}
}
func TestWrongPassword(t *testing.T) {
e := newTestEnv(t)
resp, code := e.signIn(t, "wrong password")
if code != "" || resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status %d, code %q", resp.StatusCode, code)
}
}
func TestCodeIsSingleUseAndNeedsVerifier(t *testing.T) {
e := newTestEnv(t)
_, code := e.signIn(t, "correct horse")
if status, _ := e.exchange(t, code, testVerifier+"x"); status == http.StatusOK {
t.Fatal("exchange with wrong PKCE verifier succeeded")
}
_, code = e.signIn(t, "correct horse")
if status, tr := e.exchange(t, code, testVerifier); status != http.StatusOK {
t.Fatalf("first exchange: %d %s", status, tr.Error)
}
if status, _ := e.exchange(t, code, testVerifier); status == http.StatusOK {
t.Fatal("code was accepted twice")
}
}
func TestRefreshRotatesAndRespectsDeletion(t *testing.T) {
e := newTestEnv(t)
_, code := e.signIn(t, "correct horse")
_, first := e.exchange(t, code, testVerifier)
status, second := e.refresh(t, first.RefreshToken)
if status != http.StatusOK || second.AccessToken == "" || second.RefreshToken == first.RefreshToken {
t.Fatalf("refresh: %d %s", status, second.Error)
}
if status, _ := e.refresh(t, first.RefreshToken); status == http.StatusOK {
t.Fatal("rotated-out refresh token still works")
}
if err := e.accounts.Delete("alice"); err != nil {
t.Fatal(err)
}
if status, _ := e.refresh(t, second.RefreshToken); status == http.StatusOK {
t.Fatal("refresh works after the account was deleted")
}
}
func TestRevokedRefreshTokenStopsWorking(t *testing.T) {
e := newTestEnv(t)
_, code := e.signIn(t, "correct horse")
_, tr := e.exchange(t, code, testVerifier)
resp, err := http.PostForm(e.issuer+"/revoke", url.Values{
"client_id": {"quple"},
"token": {tr.RefreshToken},
"token_type_hint": {"refresh_token"},
})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("revoke: status %d", resp.StatusCode)
}
if status, _ := e.refresh(t, tr.RefreshToken); status == http.StatusOK {
t.Fatal("revoked refresh token still works")
}
}
func TestUnregisteredRedirectIsRefused(t *testing.T) {
e := newTestEnv(t)
resp, id := e.startAuth(t, "https://evil.example/callback")
if id != "" || strings.HasPrefix(resp.Header.Get("Location"), "https://evil.example") {
t.Fatalf("authorize accepted an unregistered redirect (status %d, location %q)",
resp.StatusCode, resp.Header.Get("Location"))
}
}
func TestSocialLinkPinsProviderID(t *testing.T) {
dir := t.TempDir()
accounts, _ := LoadAccounts(filepath.Join(dir, "accounts.json"))
if err := accounts.Create("bob", ""); err != nil {
t.Fatal(err)
}
if err := accounts.Link("bob", providerGitHub, "Octocat"); err != nil {
t.Fatal(err)
}
if name, ok := accounts.FindSocial(providerGitHub, "583231", "octocat"); !ok || name != "bob" {
t.Fatalf("first sign-in: %q %v", name, ok)
}
if name, ok := accounts.FindSocial(providerGitHub, "583231", "renamed-octocat"); !ok || name != "bob" {
t.Fatalf("after rename: %q %v", name, ok)
}
if _, ok := accounts.FindSocial(providerGitHub, "999999", "octocat"); ok {
t.Fatal("a different GitHub user with the linked login got in")
}
if accounts.CheckPassword("bob", "") {
t.Fatal("empty password accepted")
}
}