Skip to content

Commit 5d3c38f

Browse files
authored
Merge pull request #57 from datum-cloud/feat/get-idp-information
Add IDP Intent Succeeded Handler with User Patch for Avatar and idpName Sync
2 parents bc124dd + bb3be10 commit 5d3c38f

File tree

4 files changed

+148
-4
lines changed

4 files changed

+148
-4
lines changed

Taskfile.yaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@ tasks:
3131
--zitadel-domain "{{.ZITADEL_DOMAIN}}" \
3232
--jwt-expiration 1h \
3333
--metrics-bind-address :8081
34-
35-
36-
34+
35+
run-actionsserver:
36+
desc: Run the HTTP actions server locally
37+
deps:
38+
- generate-webhookserver-certs
39+
cmds:
40+
- |
41+
go run cmd/main.go actions-server \
42+
--addr :8443 \
43+
--kubeconfig ~/.kube/config \
44+
--disable-signature-validation=true
3745
46+

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ require (
1111
github.com/zitadel/oidc/v3 v3.44.0
1212
github.com/zitadel/zitadel v1.80.0-v2.20.0.20250619094244-3a4298c1794a
1313
github.com/zitadel/zitadel-go/v3 v3.13.0
14-
go.miloapis.com/milo v0.6.1-0.20251022132600-85f370c87065
14+
go.miloapis.com/milo v0.12.7-0.20251209175533-fd3c212adb82
1515
go.uber.org/zap v1.27.0
1616
golang.org/x/oauth2 v0.30.0
1717
golang.org/x/sync v0.16.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ go.etcd.io/etcd/server/v3 v3.5.21 h1:9w0/k12majtgarGmlMVuhwXRI2ob3/d1Ik3X5TKo0yU
211211
go.etcd.io/etcd/server/v3 v3.5.21/go.mod h1:G1mOzdwuzKT1VRL7SqRchli/qcFrtLBTAQ4lV20sXXo=
212212
go.miloapis.com/milo v0.6.1-0.20251022132600-85f370c87065 h1:DDVrMWHgH1tL9GUy05peVNPcropgxreRWVUAZDSR8ac=
213213
go.miloapis.com/milo v0.6.1-0.20251022132600-85f370c87065/go.mod h1:oqwtjt5zV+Q07TFADtq/dZGmqw/U67VS0Pq1H+97Gi8=
214+
go.miloapis.com/milo v0.12.7-0.20251209175533-fd3c212adb82 h1:kK3Jpba59+s0Zrwo4T5aXpm/GCO1vG/IPLYPR5ljebY=
215+
go.miloapis.com/milo v0.12.7-0.20251209175533-fd3c212adb82/go.mod h1:xOFYvUsvSZV3z6eow5YdB5C/qRQf2s/5/arcfJs5XPg=
214216
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
215217
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
216218
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=

internal/httpactionsserver/server.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package httpactionsserver
22

33
import (
44
"crypto/tls"
5+
"encoding/base64"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"io"
810
"net/http"
911
"slices"
12+
"strings"
1013

1114
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1215
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -94,6 +97,7 @@ func (s *Server) Start() error {
9497
mux := http.NewServeMux()
9598
mux.HandleFunc("/v1/actions/create-user-account", s.createUserAccountHandler)
9699
mux.HandleFunc("/v1/actions/customize-jwt", s.customizeJwtHandler)
100+
mux.HandleFunc("/v1/actions/idp-intent-succeeded", s.idpIntentSucceededHandler)
97101

98102
srv := &http.Server{
99103
Addr: s.config.Addr,
@@ -218,6 +222,33 @@ type CustomizeJwtHandlerRequest struct {
218222
} `json:"user"`
219223
}
220224

225+
// IdpIntentSucceededRequest models JUST the fields we need from an idpintent.succeeded Zitadel payload.
226+
type IdpIntentSucceededRequest struct {
227+
EventType string `json:"event_type"`
228+
UserID string `json:"userId"`
229+
EventPayload struct {
230+
IDPUser string `json:"idpUser"`
231+
IDPIdToken string `json:"idpIdToken,omitempty"`
232+
UserID string `json:"userId,omitempty"`
233+
} `json:"event_payload"`
234+
}
235+
236+
// IDPUserData represents the decoded idpUser data from identity providers
237+
type IDPUserData struct {
238+
User struct {
239+
Email string `json:"email"`
240+
EmailVerified bool `json:"email_verified"`
241+
FamilyName string `json:"family_name"`
242+
GivenName string `json:"given_name"`
243+
Name string `json:"name"`
244+
Picture string `json:"picture"`
245+
Sub string `json:"sub"`
246+
// GitHub specific fields
247+
AvatarURL string `json:"avatar_url,omitempty"`
248+
Login string `json:"login,omitempty"`
249+
} `json:"User"`
250+
}
251+
221252
type Metadata struct {
222253
Key string `json:"key"`
223254
Value []byte `json:"value"`
@@ -298,3 +329,105 @@ func (s *Server) customizeJwtHandler(w http.ResponseWriter, r *http.Request) {
298329
}
299330
log.Info("Successfully wrote response")
300331
}
332+
333+
// idpIntentSucceededHandler processes the idpintent.succeeded action to capture the IDP provider and avatar URL.
334+
func (s *Server) idpIntentSucceededHandler(w http.ResponseWriter, r *http.Request) {
335+
log := logf.FromContext(r.Context()).WithName("idpIntentSucceededHandler")
336+
337+
bodyBytes, err := io.ReadAll(r.Body)
338+
if err != nil {
339+
log.Error(err, "Failed to read request body")
340+
http.Error(w, "failed to read request body", http.StatusBadRequest)
341+
return
342+
}
343+
344+
if err := s.validateSignature(bodyBytes, r.Header.Get("Zitadel-Signature"), s.config.SigningKey); err != nil {
345+
log.Error(err, "Signature validation failed")
346+
http.Error(w, fmt.Sprintf("signature validation failed: %v", err), http.StatusUnauthorized)
347+
return
348+
}
349+
350+
var req IdpIntentSucceededRequest
351+
if err := json.Unmarshal(bodyBytes, &req); err != nil {
352+
log.Error(err, "Failed to parse body")
353+
http.Error(w, "invalid body", http.StatusBadRequest)
354+
return
355+
}
356+
357+
if req.EventType != "idpintent.succeeded" || req.EventPayload.IDPUser == "" {
358+
log.Error(nil, "Unexpected event type", "eventType", req.EventType)
359+
http.Error(w, "unexpected event", http.StatusBadRequest)
360+
return
361+
}
362+
363+
// Decode idpUser JSON
364+
raw, err := base64.StdEncoding.DecodeString(req.EventPayload.IDPUser)
365+
if err != nil {
366+
log.Error(err, "Failed to decode idpUser")
367+
http.Error(w, "invalid idpUser", http.StatusBadRequest)
368+
return
369+
}
370+
371+
// Detect provider & avatar generically (supports Google & GitHub)
372+
idpProvider, avatarURL, perr := parseIDPUserData(raw)
373+
if perr != nil {
374+
log.Error(perr, "Failed to parse idpUser JSON")
375+
http.Error(w, "invalid idpUser data", http.StatusBadRequest)
376+
return
377+
}
378+
379+
// Update user avatar URL and last login provider
380+
ctx := r.Context()
381+
current := &iammiloapiscomv1alpha1.User{}
382+
if err := s.k8sClient.Get(ctx, client.ObjectKey{Name: req.EventPayload.UserID}, current); err != nil {
383+
log.Error(err, "Failed to get User resource", "userId", req.EventPayload.UserID)
384+
http.Error(w, "user not found", http.StatusNotFound)
385+
return
386+
}
387+
original := current.DeepCopy()
388+
389+
current.Status.AvatarURL = avatarURL
390+
current.Status.LastLoginProvider = idpProvider
391+
392+
fieldManagerName := "idp-intent-succeeded"
393+
if err := s.k8sClient.Status().Patch(ctx, current, client.MergeFrom(original), client.FieldOwner(fieldManagerName)); err != nil {
394+
log.Error(err, "Failed to patch User status")
395+
http.Error(w, "failed to patch user", http.StatusInternalServerError)
396+
return
397+
}
398+
399+
log.Info("Processed idpintent.succeeded", "idpProvider", idpProvider, "avatarURL", avatarURL, "userId", req.EventPayload.UserID)
400+
w.WriteHeader(http.StatusOK)
401+
_, _ = w.Write([]byte("success"))
402+
}
403+
404+
// parseIDPUserData inspects the raw json of idpUser (base64 decoded) and
405+
// returns provider and avatar URL best effort without relying on rigid structs.
406+
func parseIDPUserData(raw []byte) (iammiloapiscomv1alpha1.AuthProvider, string, error) {
407+
var m map[string]interface{}
408+
if err := json.Unmarshal(raw, &m); err != nil {
409+
return "", "", err
410+
}
411+
412+
// Google format: {"User": {"picture": "..."}}
413+
if user, ok := m["User"].(map[string]interface{}); ok {
414+
if pic, ok := user["picture"].(string); ok && pic != "" {
415+
return iammiloapiscomv1alpha1.AuthProviderGoogle, pic, nil
416+
}
417+
}
418+
419+
// GitHub format: top-level avatar_url
420+
if avatar, ok := m["avatar_url"].(string); ok && avatar != "" {
421+
return iammiloapiscomv1alpha1.AuthProviderGitHub, avatar, nil
422+
}
423+
424+
// Also google picture could be top-level
425+
if pic, ok := m["picture"].(string); ok && pic != "" {
426+
if strings.Contains(pic, "googleusercontent.com") {
427+
return iammiloapiscomv1alpha1.AuthProviderGoogle, pic, nil
428+
}
429+
430+
}
431+
432+
return "", "", errors.New("unknown idp provider")
433+
}

0 commit comments

Comments
 (0)