auth: add Keycloak OIDC verifier, role check, and test mock
This commit is contained in:
@@ -2,12 +2,18 @@ module geniusrun/backend
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/mark3labs/mcp-go v0.56.0
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/mark3labs/mcp-go v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.3.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -21,5 +27,4 @@ require (
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.53.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
@@ -8,14 +10,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -40,18 +48,46 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
41
backend/internal/auth/mock/mock.go
Normal file
41
backend/internal/auth/mock/mock.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Package mock provides a fake auth.Verifier for tests that need to exercise
|
||||
// internal/api's session endpoints and RequireSession gating without a real
|
||||
// Keycloak instance.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
)
|
||||
|
||||
// Verifier is a fake auth.Verifier returning canned results supplied by the
|
||||
// test/caller.
|
||||
type Verifier struct {
|
||||
AuthURL string
|
||||
Txn auth.TxnState
|
||||
CallbackResult auth.LoginResult
|
||||
CallbackErr error
|
||||
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
||||
}
|
||||
|
||||
var _ auth.Verifier = (*Verifier)(nil)
|
||||
|
||||
func (v *Verifier) BeginLogin() (string, auth.TxnState, error) {
|
||||
return v.AuthURL, v.Txn, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query url.Values) (auth.LoginResult, error) {
|
||||
if v.CallbackErr != nil {
|
||||
return auth.LoginResult{}, v.CallbackErr
|
||||
}
|
||||
return v.CallbackResult, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
if v.EndSessionResult != "" {
|
||||
return v.EndSessionResult
|
||||
}
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
159
backend/internal/auth/oidc.go
Normal file
159
backend/internal/auth/oidc.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Verifier is the seam between internal/api and this package's real
|
||||
// Keycloak-backed OIDC client, so tests can inject internal/auth/mock's fake
|
||||
// instead of talking to a real identity provider.
|
||||
type Verifier interface {
|
||||
// BeginLogin builds a Keycloak authorization URL and the transaction
|
||||
// state that must be round-tripped (via a TxnCookie) to HandleCallback.
|
||||
BeginLogin() (authURL string, txn TxnState, err error)
|
||||
// HandleCallback validates the callback query against txn, exchanges the
|
||||
// code, verifies the ID token, and reports whether the required role was
|
||||
// present.
|
||||
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
||||
// EndSessionURL builds the identity provider's logout URL, redirecting
|
||||
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
|
||||
EndSessionURL(postLogoutRedirectURL string) string
|
||||
}
|
||||
|
||||
// LoginResult is what a completed callback exchange resolves to.
|
||||
type LoginResult struct {
|
||||
Claims Claims
|
||||
Authorized bool
|
||||
}
|
||||
|
||||
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
||||
// ID token's realm_access.roles.
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
RequiredRole string
|
||||
}
|
||||
|
||||
// idTokenClaims mirrors the subset of a Keycloak ID token's claims geniusrun
|
||||
// cares about.
|
||||
type idTokenClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
RealmAccess struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"realm_access"`
|
||||
}
|
||||
|
||||
func (c idTokenClaims) hasRole(required string) bool {
|
||||
for _, r := range c.RealmAccess.Roles {
|
||||
if r == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type oidcVerifier struct {
|
||||
provider *oidc.Provider
|
||||
idTokenVerif *oidc.IDTokenVerifier
|
||||
oauth2Config oauth2.Config
|
||||
requiredRole string
|
||||
}
|
||||
|
||||
// NewOIDCVerifier performs OIDC discovery against cfg.IssuerURL (once, at
|
||||
// startup -- go-oidc caches the discovery document internally) and returns a
|
||||
// Verifier backed by the real Keycloak realm.
|
||||
func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err)
|
||||
}
|
||||
return &oidcVerifier{
|
||||
provider: provider,
|
||||
idTokenVerif: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
oauth2Config: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
requiredRole: cfg.RequiredRole,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate random state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) BeginLogin() (string, TxnState, error) {
|
||||
state, err := randomString(24)
|
||||
if err != nil {
|
||||
return "", TxnState{}, err
|
||||
}
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
authURL := v.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
return authURL, TxnState{State: state, CodeVerifier: verifier}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) {
|
||||
if got := query.Get("state"); got == "" || got != txn.State {
|
||||
return LoginResult{}, fmt.Errorf("state mismatch")
|
||||
}
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
return LoginResult{}, fmt.Errorf("callback missing code")
|
||||
}
|
||||
token, err := v.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(txn.CodeVerifier))
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return LoginResult{}, fmt.Errorf("token response missing id_token")
|
||||
}
|
||||
idToken, err := v.idTokenVerif.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
var claims idTokenClaims
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return LoginResult{
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||
Authorized: claims.hasRole(v.requiredRole),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
var discovery struct {
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
if err := v.provider.Claims(&discovery); err != nil || discovery.EndSessionEndpoint == "" {
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
u, err := url.Parse(discovery.EndSessionEndpoint)
|
||||
if err != nil {
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", v.oauth2Config.ClientID)
|
||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
31
backend/internal/auth/oidc_test.go
Normal file
31
backend/internal/auth/oidc_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIDTokenClaims_HasRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
role string
|
||||
want bool
|
||||
}{
|
||||
{"role present among others", `{"realm_access":{"roles":["geniusrun-user","other"]}}`, "geniusrun-user", true},
|
||||
{"role absent", `{"realm_access":{"roles":["other"]}}`, "geniusrun-user", false},
|
||||
{"realm_access missing entirely", `{}`, "geniusrun-user", false},
|
||||
{"roles array empty", `{"realm_access":{"roles":[]}}`, "geniusrun-user", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var c idTokenClaims
|
||||
if err := json.Unmarshal([]byte(tc.json), &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got := c.hasRole(tc.role); got != tc.want {
|
||||
t.Errorf("hasRole(%q) = %v, want %v", tc.role, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user