Skip to content

Commit 12ffdfb

Browse files
authored
Implementing the SAML provider config management API (#278)
* Adding SAMLProviderConfig API * Added DeleteSAMLProviderConfig and error handling * Minor cleanup
1 parent 21bc0d7 commit 12ffdfb

File tree

5 files changed

+332
-2
lines changed

5 files changed

+332
-2
lines changed

auth/auth.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ var reservedClaims = []string{
4141
// by Firebase backend services.
4242
type Client struct {
4343
userManagementClient
44+
pcc *providerConfigClient // TODO: Embed this to add the methods to the public API
4445
idTokenVerifier *tokenVerifier
4546
cookieVerifier *tokenVerifier
4647
signer cryptoSigner
@@ -99,6 +100,7 @@ func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error)
99100

100101
return &Client{
101102
userManagementClient: *userMgt,
103+
pcc: newProviderConfigClient(userMgt.httpClient.Client, conf),
102104
idTokenVerifier: idTokenVerifier,
103105
cookieVerifier: cookieVerifier,
104106
signer: signer,

auth/provider_config.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright 2019 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"net/http"
22+
"strings"
23+
24+
"firebase.google.com/go/internal"
25+
)
26+
27+
const providerConfigEndpoint = "https://identitytoolkit.googleapis.com/v2beta1"
28+
29+
// SAMLProviderConfig is the SAML auth provider configuration.
30+
// See http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html.
31+
type SAMLProviderConfig struct {
32+
ID string
33+
DisplayName string
34+
Enabled bool
35+
IDPEntityID string
36+
SSOURL string
37+
RequestSigningEnabled bool
38+
X509Certificates []string
39+
RPEntityID string
40+
CallbackURL string
41+
}
42+
43+
type providerConfigClient struct {
44+
endpoint string
45+
projectID string
46+
httpClient *internal.HTTPClient
47+
}
48+
49+
func newProviderConfigClient(hc *http.Client, conf *internal.AuthConfig) *providerConfigClient {
50+
client := &internal.HTTPClient{
51+
Client: hc,
52+
SuccessFn: internal.HasSuccessStatus,
53+
CreateErrFn: handleHTTPError,
54+
Opts: []internal.HTTPOption{
55+
internal.WithHeader("X-Client-Version", fmt.Sprintf("Go/Admin/%s", conf.Version)),
56+
},
57+
}
58+
return &providerConfigClient{
59+
endpoint: providerConfigEndpoint,
60+
projectID: conf.ProjectID,
61+
httpClient: client,
62+
}
63+
}
64+
65+
// SAMLProviderConfig returns the SAMLProviderConfig with the given ID.
66+
func (c *providerConfigClient) SAMLProviderConfig(ctx context.Context, id string) (*SAMLProviderConfig, error) {
67+
if err := validateSAMLConfigID(id); err != nil {
68+
return nil, err
69+
}
70+
71+
req := &internal.Request{
72+
Method: http.MethodGet,
73+
URL: fmt.Sprintf("/inboundSamlConfigs/%s", id),
74+
}
75+
var result samlProviderConfigDAO
76+
if _, err := c.makeRequest(ctx, req, &result); err != nil {
77+
return nil, err
78+
}
79+
80+
return result.toSAMLProviderConfig(), nil
81+
}
82+
83+
// DeleteSAMLProviderConfig deletes the SAMLProviderConfig with the given ID.
84+
func (c *providerConfigClient) DeleteSAMLProviderConfig(ctx context.Context, id string) error {
85+
if err := validateSAMLConfigID(id); err != nil {
86+
return err
87+
}
88+
89+
req := &internal.Request{
90+
Method: http.MethodDelete,
91+
URL: fmt.Sprintf("/inboundSamlConfigs/%s", id),
92+
}
93+
_, err := c.makeRequest(ctx, req, nil)
94+
return err
95+
}
96+
97+
func (c *providerConfigClient) makeRequest(ctx context.Context, req *internal.Request, v interface{}) (*internal.Response, error) {
98+
if c.projectID == "" {
99+
return nil, errors.New("project id not available")
100+
}
101+
102+
req.URL = fmt.Sprintf("%s/projects/%s%s", c.endpoint, c.projectID, req.URL)
103+
return c.httpClient.DoAndUnmarshal(ctx, req, v)
104+
}
105+
106+
type samlProviderConfigDAO struct {
107+
Name string `json:"name"`
108+
IDPConfig struct {
109+
IDPEntityID string `json:"idpEntityId"`
110+
SSOURL string `json:"ssoUrl"`
111+
IDPCertificates []struct {
112+
X509Certificate string `json:"x509Certificate"`
113+
} `json:"idpCertificates"`
114+
SignRequest bool `json:"signRequest"`
115+
} `json:"idpConfig"`
116+
SPConfig struct {
117+
SPEntityID string `json:"spEntityId"`
118+
CallbackURI string `json:"callbackUri"`
119+
} `json:"spConfig"`
120+
DisplayName string `json:"displayName"`
121+
Enabled bool `json:"enabled"`
122+
}
123+
124+
func (dao *samlProviderConfigDAO) toSAMLProviderConfig() *SAMLProviderConfig {
125+
var certs []string
126+
for _, cert := range dao.IDPConfig.IDPCertificates {
127+
certs = append(certs, cert.X509Certificate)
128+
}
129+
130+
return &SAMLProviderConfig{
131+
ID: extractResourceID(dao.Name),
132+
DisplayName: dao.DisplayName,
133+
Enabled: dao.Enabled,
134+
IDPEntityID: dao.IDPConfig.IDPEntityID,
135+
SSOURL: dao.IDPConfig.SSOURL,
136+
RequestSigningEnabled: dao.IDPConfig.SignRequest,
137+
X509Certificates: certs,
138+
RPEntityID: dao.SPConfig.SPEntityID,
139+
CallbackURL: dao.SPConfig.CallbackURI,
140+
}
141+
}
142+
143+
func validateSAMLConfigID(id string) error {
144+
if !strings.HasPrefix(id, "saml.") {
145+
return fmt.Errorf("invalid SAML provider id: %q", id)
146+
}
147+
148+
return nil
149+
}
150+
151+
func extractResourceID(name string) string {
152+
// name format: "projects/project-id/resource/resource-id"
153+
segments := strings.Split(name, "/")
154+
return segments[len(segments)-1]
155+
}

auth/provider_config_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright 2019 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"net/http"
20+
"reflect"
21+
"strings"
22+
"testing"
23+
)
24+
25+
const samlConfigResponse = `{
26+
"name":"projects/mock-project-id/inboundSamlConfigs/saml.provider",
27+
"idpConfig": {
28+
"idpEntityId": "IDP_ENTITY_ID",
29+
"ssoUrl": "https://example.com/login",
30+
"signRequest": true,
31+
"idpCertificates": [
32+
{"x509Certificate": "CERT1"},
33+
{"x509Certificate": "CERT2"}
34+
]
35+
},
36+
"spConfig": {
37+
"spEntityId": "RP_ENTITY_ID",
38+
"callbackUri": "https://projectId.firebaseapp.com/__/auth/handler"
39+
},
40+
"displayName": "samlProviderName",
41+
"enabled": true
42+
}`
43+
const notFoundResponse = `{
44+
"error": {
45+
"message": "CONFIGURATION_NOT_FOUND"
46+
}
47+
}`
48+
49+
var samlProviderConfig = &SAMLProviderConfig{
50+
ID: "saml.provider",
51+
DisplayName: "samlProviderName",
52+
Enabled: true,
53+
IDPEntityID: "IDP_ENTITY_ID",
54+
SSOURL: "https://example.com/login",
55+
RequestSigningEnabled: true,
56+
X509Certificates: []string{"CERT1", "CERT2"},
57+
RPEntityID: "RP_ENTITY_ID",
58+
CallbackURL: "https://projectId.firebaseapp.com/__/auth/handler",
59+
}
60+
61+
var invalidSAMLConfigIDs = []string{
62+
"",
63+
"invalid.id",
64+
"oidc.config",
65+
}
66+
67+
func TestSAMLProviderConfig(t *testing.T) {
68+
s := echoServer([]byte(samlConfigResponse), t)
69+
defer s.Close()
70+
71+
client := s.Client.pcc
72+
saml, err := client.SAMLProviderConfig(context.Background(), "saml.provider")
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
77+
if !reflect.DeepEqual(saml, samlProviderConfig) {
78+
t.Errorf("SAMLProviderConfig() = %#v; want = %#v", saml, samlProviderConfig)
79+
}
80+
81+
req := s.Req[0]
82+
if req.Method != http.MethodGet {
83+
t.Errorf("SAMLProviderConfig() Method = %q; want = %q", req.Method, http.MethodGet)
84+
}
85+
86+
wantURL := "/projects/mock-project-id/inboundSamlConfigs/saml.provider"
87+
if req.URL.Path != wantURL {
88+
t.Errorf("SAMLProviderConfig() URL = %q; want = %q", req.URL.Path, wantURL)
89+
}
90+
}
91+
92+
func TestSAMLProviderConfigInvalidID(t *testing.T) {
93+
client := &providerConfigClient{}
94+
wantErr := "invalid SAML provider id: "
95+
96+
for _, id := range invalidSAMLConfigIDs {
97+
saml, err := client.SAMLProviderConfig(context.Background(), id)
98+
if saml != nil || err == nil || !strings.HasPrefix(err.Error(), wantErr) {
99+
t.Errorf("SAMLProviderConfig(%q) = (%v, %v); want = (nil, %q)", id, saml, err, wantErr)
100+
}
101+
}
102+
}
103+
104+
func TestSAMLProviderConfigError(t *testing.T) {
105+
s := echoServer([]byte(notFoundResponse), t)
106+
defer s.Close()
107+
s.Status = http.StatusNotFound
108+
109+
client := s.Client.pcc
110+
saml, err := client.SAMLProviderConfig(context.Background(), "saml.provider")
111+
if saml != nil || err == nil || !IsConfigurationNotFound(err) {
112+
t.Errorf("SAMLProviderConfig() = (%v, %v); want = (nil, ConfigurationNotFound)", saml, err)
113+
}
114+
}
115+
116+
func TestDeleteSAMLProviderConfig(t *testing.T) {
117+
s := echoServer([]byte("{}"), t)
118+
defer s.Close()
119+
120+
client := s.Client.pcc
121+
if err := client.DeleteSAMLProviderConfig(context.Background(), "saml.provider"); err != nil {
122+
t.Fatal(err)
123+
}
124+
125+
req := s.Req[0]
126+
if req.Method != http.MethodDelete {
127+
t.Errorf("DeleteSAMLProviderConfig() Method = %q; want = %q", req.Method, http.MethodDelete)
128+
}
129+
130+
wantURL := "/projects/mock-project-id/inboundSamlConfigs/saml.provider"
131+
if req.URL.Path != wantURL {
132+
t.Errorf("DeleteSAMLProviderConfig() URL = %q; want = %q", req.URL.Path, wantURL)
133+
}
134+
}
135+
136+
func TestDeleteSAMLProviderConfigInvalidID(t *testing.T) {
137+
client := &providerConfigClient{}
138+
wantErr := "invalid SAML provider id: "
139+
140+
for _, id := range invalidSAMLConfigIDs {
141+
err := client.DeleteSAMLProviderConfig(context.Background(), id)
142+
if err == nil || !strings.HasPrefix(err.Error(), wantErr) {
143+
t.Errorf("DeleteSAMLProviderConfig(%q) = %v; want = %q", id, err, wantErr)
144+
}
145+
}
146+
}
147+
148+
func TestDeleteSAMLProviderConfigError(t *testing.T) {
149+
s := echoServer([]byte(notFoundResponse), t)
150+
defer s.Close()
151+
s.Status = http.StatusNotFound
152+
153+
client := s.Client.pcc
154+
err := client.DeleteSAMLProviderConfig(context.Background(), "saml.provider")
155+
if err == nil || !IsConfigurationNotFound(err) {
156+
t.Errorf("DeleteSAMLProviderConfig() = %v; want = ConfigurationNotFound", err)
157+
}
158+
}
159+
160+
func TestSAMLProviderConfigNoProjectID(t *testing.T) {
161+
client := &providerConfigClient{}
162+
want := "project id not available"
163+
if _, err := client.SAMLProviderConfig(context.Background(), "saml.provider"); err == nil || err.Error() != want {
164+
t.Errorf("SAMLProviderConfig() = %v; want = %q", err, want)
165+
}
166+
}

auth/user_mgt.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ func marshalCustomClaims(claims map[string]interface{}) (string, error) {
315315
// Error handlers.
316316

317317
const (
318+
configurationNotFound = "configuration-not-found"
318319
emailAlreadyExists = "email-already-exists"
319320
idTokenRevoked = "id-token-revoked"
320321
insufficientPermission = "insufficient-permission"
@@ -328,6 +329,11 @@ const (
328329
userNotFound = "user-not-found"
329330
)
330331

332+
// IsConfigurationNotFound checks if the given error was due to a non-existing IdP configuration.
333+
func IsConfigurationNotFound(err error) bool {
334+
return internal.HasErrorCode(err, configurationNotFound)
335+
}
336+
331337
// IsEmailAlreadyExists checks if the given error was due to a duplicate email.
332338
func IsEmailAlreadyExists(err error) bool {
333339
return internal.HasErrorCode(err, emailAlreadyExists)
@@ -384,7 +390,7 @@ func IsUserNotFound(err error) bool {
384390
}
385391

386392
var serverError = map[string]string{
387-
"CONFIGURATION_NOT_FOUND": projectNotFound,
393+
"CONFIGURATION_NOT_FOUND": configurationNotFound,
388394
"DUPLICATE_EMAIL": emailAlreadyExists,
389395
"DUPLICATE_LOCAL_ID": uidAlreadyExists,
390396
"EMAIL_EXISTS": emailAlreadyExists,

auth/user_mgt_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1183,7 +1183,7 @@ func TestHTTPError(t *testing.T) {
11831183

11841184
func TestHTTPErrorWithCode(t *testing.T) {
11851185
errorCodes := map[string]func(error) bool{
1186-
"CONFIGURATION_NOT_FOUND": IsProjectNotFound,
1186+
"CONFIGURATION_NOT_FOUND": IsConfigurationNotFound,
11871187
"DUPLICATE_EMAIL": IsEmailAlreadyExists,
11881188
"DUPLICATE_LOCAL_ID": IsUIDAlreadyExists,
11891189
"EMAIL_EXISTS": IsEmailAlreadyExists,
@@ -1288,6 +1288,7 @@ func echoServer(resp interface{}, t *testing.T) *mockAuthServer {
12881288
t.Fatal(err)
12891289
}
12901290
authClient.baseURL = s.Srv.URL
1291+
authClient.pcc.endpoint = s.Srv.URL
12911292
s.Client = authClient
12921293
return &s
12931294
}

0 commit comments

Comments
 (0)