lib/persona/broker.go (315 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package persona provides a persona broker for use by clients.
package persona
import (
"context"
"encoding/base64"
"html/template"
"net/http"
"net/url"
"strings"
"github.com/go-jose/go-jose/v3" /* copybara-comment */
"github.com/gorilla/mux" /* copybara-comment */
"google.golang.org/grpc/codes" /* copybara-comment */
"google.golang.org/grpc/status" /* copybara-comment */
"github.com/pborman/uuid" /* copybara-comment */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/ga4gh" /* copybara-comment: ga4gh */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/globalflags" /* copybara-comment: globalflags */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/httputils" /* copybara-comment: httputils */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/kms/localsign" /* copybara-comment: localsign */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/srcutil" /* copybara-comment: srcutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/storage" /* copybara-comment: storage */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/strutil" /* copybara-comment: strutil */
"github.com/GoogleCloudPlatform/healthcare-federated-access-services/lib/testkeys" /* copybara-comment: testkeys */
glog "github.com/golang/glog" /* copybara-comment */
cpb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/common/v1" /* copybara-comment: go_proto */
dampb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/dam/v1" /* copybara-comment: go_proto */
ipb "github.com/GoogleCloudPlatform/healthcare-federated-access-services/proto/ic/v1" /* copybara-comment: go_proto */
)
const (
loginPageFile = "pages/login.html"
loginPageInfoFile = "pages/personas/login_info.html"
staticDirectory = "assets/serve/"
serviceTitle = "Persona Playground"
loginInfoTitle = "Persona Playground"
)
// Server is a fake OIDC passport broker service for a playground
// or test environment. Private keys are well-known and allows any
// user to act as system administrator.
// WARNING: ONLY for use with synthetic or test data.
//
// Do not use unless you fully understand the security and privacy implications.
type Server struct {
IssuerURL string
key *testkeys.Key
cfg *dampb.DamConfig
Handler *mux.Router
loginPageTmpl *template.Template
}
// NewBroker returns a Persona Broker Server
func NewBroker(issuerURL string, key *testkeys.Key, service, path string, useOIDCPrefix bool) (*Server, error) {
var cfg *dampb.DamConfig
if len(service) > 0 && len(path) > 0 {
cfg = &dampb.DamConfig{}
store := storage.NewMemoryStorage(service, path)
if err := store.ReadTx(storage.ConfigDatatype, storage.DefaultRealm, storage.DefaultUser, storage.DefaultID, storage.LatestRev, cfg, nil); err != nil {
return nil, err
}
}
loginPageTmpl, err := httputils.TemplateFromFiles(loginPageFile, loginPageInfoFile)
if err != nil {
glog.Exitf("cannot create template for login page: %v", err)
}
s := &Server{
IssuerURL: issuerURL,
key: key,
cfg: cfg,
loginPageTmpl: loginPageTmpl,
}
r := mux.NewRouter()
s.Handler = r
registerHandlers(r, s, useOIDCPrefix)
return s, nil
}
// Sign the jwt with the private key in Server.
func (s *Server) Sign(header map[string]string, claim any) (string, error) {
signer := signer(s.key)
return signer.SignJWT(context.Background(), claim, header)
}
// Config returns the DAM configuration currently in use.
func (s *Server) Config() *dampb.DamConfig {
return s.cfg
}
func (s *Server) oidcWellKnownConfig(w http.ResponseWriter, r *http.Request) {
conf := &cpb.OidcConfig{
Issuer: s.IssuerURL,
AuthEndpoint: s.IssuerURL + oidcAuthorizePath,
TokenEndpoint: s.IssuerURL + oidcTokenPath,
JwksUri: s.IssuerURL + oidcJwksPath,
UserinfoEndpoint: s.IssuerURL + oidcUserInfoPath,
}
httputils.WriteNonProtoResp(w, conf)
}
func (s *Server) oidcKeys(w http.ResponseWriter, r *http.Request) {
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: s.key.Public,
Algorithm: string(globalflags.LocalSignerAlgorithm),
Use: "sig",
KeyID: s.key.ID,
},
},
}
httputils.WriteNonProtoResp(w, jwks)
}
func (s *Server) oidcUserInfo(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
parts := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
httputils.WriteError(w, status.Errorf(codes.PermissionDenied, "missing or invalid Authorization header"))
return
}
token := parts[1]
var sub string
scope := "openid profile identities ga4gh_passport_v1 email"
if strings.HasPrefix(token, "opaque:") {
sub = strings.TrimPrefix(token, "opaque:")
} else {
if err := ga4gh.VerifyTokenWithKey(s.key.Public, token); err != nil {
httputils.WriteError(w, status.Errorf(codes.Unauthenticated, "invalid token: %v", err))
return
}
src, err := ga4gh.ConvertTokenToIdentityUnsafe(token)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.PermissionDenied, "invalid Authorization token"))
return
}
sub = src.Subject
scope = src.Scope
}
var persona *cpb.TestPersona
var pname string
for pn, p := range s.cfg.TestPersonas {
if pn == sub || (p.Passport.StandardClaims != nil && p.Passport.StandardClaims["sub"] == sub) {
pname = pn
persona = p
break
}
}
if persona == nil {
httputils.WriteError(w, status.Errorf(codes.PermissionDenied, "persona %q not found", sub))
return
}
id, err := ToIdentity(r.Context(), pname, persona, scope, s.IssuerURL)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.PermissionDenied, "preparing persona %q: %v", sub, err))
return
}
httputils.WriteNonProtoResp(w, id)
}
func (s *Server) oidcAuthorize(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
typ := httputils.QueryParam(r, "response_type")
if typ != "code" {
httputils.WriteError(w, status.Errorf(codes.InvalidArgument, "response type must be %q", "code"))
return
}
redirect, err := url.QueryUnescape(r.URL.Query().Get("redirect_uri"))
if err != nil {
httputils.WriteError(w, status.Errorf(codes.InvalidArgument, "redirect_uri must be a valid URL: %v", err))
return
}
if redirect == "" {
httputils.WriteError(w, status.Errorf(codes.InvalidArgument, "redirect_uri must be specified"))
return
}
u, err := url.Parse(redirect)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.NotFound, "invalid redirect_uri URL format: %v", err))
return
}
state := httputils.QueryParam(r, "state")
nonce := httputils.QueryParam(r, "nonce")
clientID := httputils.QueryParam(r, "client_id")
scope := httputils.QueryParam(r, "scope")
loginHint := httputils.QueryParam(r, "login_hint")
if len(loginHint) == 0 {
s.sendLoginPage(u.String(), state, nonce, clientID, scope, w, r)
return
}
pname := loginHint
_, ok := s.cfg.TestPersonas[pname]
if !ok {
httputils.WriteError(w, status.Errorf(codes.NotFound, "persona %q not found", pname))
return
}
code := pname
if len(clientID) > 0 {
code = code + "," + clientID
}
q := u.Query()
q.Set("code", code)
q.Set("scope", scope)
q.Set("state", state)
q.Set("nonce", nonce)
u.RawQuery = q.Encode()
httputils.WriteRedirect(w, r, u.String())
}
func (s *Server) sendLoginPage(redirect, state, nonce, clientID, scope string, w http.ResponseWriter, r *http.Request) {
list := &ipb.LoginPageProviders{Personas: make(map[string]*ipb.LoginPageProviders_ProviderEntry)}
for pname, p := range s.cfg.TestPersonas {
ui := p.Ui
if ui == nil {
ui = make(map[string]string)
}
if _, ok := ui["label"]; !ok {
ui["label"] = strutil.ToTitle(pname)
}
params := url.Values{}
params.Add("login_hint", pname)
params.Add("scope", scope)
params.Add("redirect_uri", redirect)
params.Add("state", state)
params.Add("nonce=", nonce)
params.Add("client_id", clientID)
params.Add("response_type", "code")
u, err := url.Parse(r.URL.String())
if err != nil {
httputils.WriteError(w, status.Errorf(codes.Internal, "%v", err))
return
}
u.RawQuery = params.Encode()
list.Personas[pname] = &ipb.LoginPageProviders_ProviderEntry{
Url: u.String(),
Ui: ui,
}
}
args := &loginPageArgs{
ProviderList: list,
AssetDir: "/static",
ServiceTitle: serviceTitle,
LoginInfoTitle: loginInfoTitle,
}
if err := s.loginPageTmpl.Execute(w, args); err != nil {
httputils.WriteError(w, status.Errorf(codes.Internal, "%v", err))
}
}
type loginPageArgs struct {
ProviderList *ipb.LoginPageProviders
AssetDir string
ServiceTitle string
LoginInfoTitle string
}
func basicAuthClientID(r *http.Request) string {
auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(auth) != 2 || auth[0] != "Basic" {
return ""
}
payload, _ := base64.StdEncoding.DecodeString(auth[1])
pair := strings.SplitN(string(payload), ":", 2)
if len(pair) != 2 {
return ""
}
return pair[0]
}
func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
clientID := httputils.QueryParam(r, "client_id")
if len(clientID) == 0 {
clientID = basicAuthClientID(r)
}
var code string
switch httputils.QueryParam(r, "grant_type") {
case "refresh_token":
code = httputils.QueryParam(r, "refresh_token")
default:
code = httputils.QueryParam(r, "code")
}
parts := strings.SplitN(code, ",", 2)
pname := parts[0]
if len(parts) > 1 {
clientID = parts[1]
}
persona, ok := s.cfg.TestPersonas[pname]
if !ok {
httputils.WriteError(w, status.Errorf(codes.NotFound, "persona %q not found", pname))
return
}
scope := httputils.QueryParam(r, "scope")
if len(scope) == 0 {
scope = DefaultScope
if len(persona.Passport.ExtraScopes) > 0 {
scope = scope + " " + persona.Passport.ExtraScopes
}
}
acTok, _, err := NewAccessToken(pname, s.IssuerURL, clientID, scope, persona)
if err != nil {
httputils.WriteError(w, status.Errorf(codes.Internal, "error creating access token for persona %q: %v", pname, err))
return
}
refreshTok := pname
if len(clientID) > 0 {
refreshTok = pname + "," + clientID
}
resp := &cpb.OidcTokenResponse{
AccessToken: string(acTok),
RefreshToken: refreshTok,
TokenType: "bearer",
ExpiresIn: 60 * 60 * 24 * 365,
Uid: uuid.New(),
}
httputils.WriteResp(w, resp)
}
// TODO: move registeration of endpoints to main package.
func registerHandlers(r *mux.Router, s *Server, useOIDCPrefix bool) {
if useOIDCPrefix {
r.HandleFunc("/oidc"+oidcConfiguarePath, s.oidcWellKnownConfig)
r.HandleFunc("/oidc"+oidcJwksPath, s.oidcKeys)
r.HandleFunc("/oidc"+oidcAuthorizePath, s.oidcAuthorize)
r.HandleFunc("/oidc"+oidcTokenPath, s.oidcToken)
r.HandleFunc("/oidc"+oidcUserInfoPath, s.oidcUserInfo)
} else {
r.HandleFunc(oidcConfiguarePath, s.oidcWellKnownConfig)
r.HandleFunc(oidcJwksPath, s.oidcKeys)
r.HandleFunc(oidcAuthorizePath, s.oidcAuthorize)
r.HandleFunc(oidcTokenPath, s.oidcToken)
r.HandleFunc(oidcUserInfoPath, s.oidcUserInfo)
}
sfs := http.StripPrefix(staticFilePath, http.FileServer(http.Dir(srcutil.Path(staticDirectory))))
r.PathPrefix(staticFilePath).Handler(sfs)
}
func signer(key *testkeys.Key) *localsign.Signer {
if globalflags.LocalSignerAlgorithm == globalflags.RS384 {
return localsign.NewRS384Signer(key)
}
return localsign.New(key)
}