mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-07 06:52:20 +00:00
# Which Problems Are Solved - The previous monorepo in monorepo structure for the login app and its related packages was fragmented, complicated and buggy. - The process for building and testing the login container was inconsistent between local development and CI. - Lack of clear documentation as well as easy and reliable ways for non-frontend developers to reproduce and fix failing PR checks locally. # How the Problems Are Solved - Consolidated the login app and its related npm packages by moving the main package to `apps/login/apps/login` and merging `apps/login/packages/integration` and `apps/login/packages/acceptance` into the main `apps/login` package. - Migrated from Docker Compose-based test setups to dev container-based setups, adding support for multiple dev container configurations: - `.devcontainer/base` - `.devcontainer/turbo-lint-unit` - `.devcontainer/turbo-lint-unit-debug` - `.devcontainer/login-integration` - `.devcontainer/login-integration-debug` - Added npm scripts to run the new dev container setups, enabling exact reproduction of GitHub PR checks locally, and updated the pipeline to use these containers. - Cleaned up Dockerfiles and docker-bake.hcl files to only build the production image for the login app. - Cleaned up compose files to focus on dev environments in dev containers. - Updated `CONTRIBUTING.md` with guidance on running and debugging PR checks locally using the new dev container approach. - Introduced separate Dockerfiles for the login app to distinguish between using published client packages and building clients from local protos. - Ensured the login container is always built in the pipeline for use in integration and acceptance tests. - Updated Makefile and GitHub Actions workflows to use `--frozen-lockfile` for installing pnpm packages, ensuring reproducible installs. - Disabled GitHub release creation by the changeset action. - Refactored the `/build` directory structure for clarity and maintainability. - Added a `clean` command to `docks/package.json`. - Experimentally added `knip` to the `zitadel-client` package for improved linting of dependencies and exports. # Additional Changes - Fixed Makefile commands for consistency and reliability. - Improved the structure and clarity of the `/build` directory to support seamless integration of the login build. - Enhanced documentation and developer experience for running and debugging CI checks locally. # Additional Context - See updated `CONTRIBUTING.md` for new local development and debugging instructions. - These changes are a prerequisite for further improvements to the CI pipeline and local development workflow. - Closes #10276
323 lines
9.6 KiB
Go
323 lines
9.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/zitadel/logging"
|
|
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
|
httphelper "github.com/zitadel/oidc/v3/pkg/http"
|
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
|
)
|
|
|
|
var (
|
|
callbackPath = "/auth/callback"
|
|
key = []byte("test1234test1234")
|
|
)
|
|
|
|
func main() {
|
|
apiURL := os.Getenv("API_URL")
|
|
pat := readPAT(os.Getenv("PAT_FILE"))
|
|
domain := os.Getenv("API_DOMAIN")
|
|
loginURL := os.Getenv("LOGIN_URL")
|
|
issuer := os.Getenv("ISSUER")
|
|
port := os.Getenv("PORT")
|
|
scopeList := strings.Split(os.Getenv("SCOPES"), " ")
|
|
|
|
redirectURI := fmt.Sprintf("%s%s", issuer, callbackPath)
|
|
cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure())
|
|
|
|
clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
logger := slog.New(
|
|
slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
|
AddSource: true,
|
|
Level: slog.LevelDebug,
|
|
}),
|
|
)
|
|
client := &http.Client{
|
|
Timeout: time.Minute,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
// enable outgoing request logging
|
|
logging.EnableHTTPClient(client,
|
|
logging.WithClientGroup("client"),
|
|
)
|
|
|
|
options := []rp.Option{
|
|
rp.WithCookieHandler(cookieHandler),
|
|
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
|
|
rp.WithHTTPClient(client),
|
|
rp.WithLogger(logger),
|
|
rp.WithSigningAlgsFromDiscovery(),
|
|
rp.WithCustomDiscoveryUrl(issuer + "/.well-known/openid-configuration"),
|
|
}
|
|
if clientSecret == "" {
|
|
options = append(options, rp.WithPKCE(cookieHandler))
|
|
}
|
|
|
|
// One can add a logger to the context,
|
|
// pre-defining log attributes as required.
|
|
ctx := logging.ToContext(context.TODO(), logger)
|
|
provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...)
|
|
if err != nil {
|
|
logrus.Fatalf("error creating provider %s", err.Error())
|
|
}
|
|
|
|
// generate some state (representing the state of the user in your application,
|
|
// e.g. the page where he was before sending him to login
|
|
state := func() string {
|
|
return uuid.New().String()
|
|
}
|
|
|
|
urlOptions := []rp.URLParamOpt{
|
|
rp.WithPromptURLParam("Welcome back!"),
|
|
}
|
|
|
|
// register the AuthURLHandler at your preferred path.
|
|
// the AuthURLHandler creates the auth request and redirects the user to the auth server.
|
|
// including state handling with secure cookie and the possibility to use PKCE.
|
|
// Prompts can optionally be set to inform the server of
|
|
// any messages that need to be prompted back to the user.
|
|
http.Handle("/login", rp.AuthURLHandler(
|
|
state,
|
|
provider,
|
|
urlOptions...,
|
|
))
|
|
|
|
// for demonstration purposes the returned userinfo response is written as JSON object onto response
|
|
marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) {
|
|
fmt.Println("access token", tokens.AccessToken)
|
|
fmt.Println("refresh token", tokens.RefreshToken)
|
|
fmt.Println("id token", tokens.IDToken)
|
|
|
|
data, err := json.Marshal(info)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("content-type", "application/json")
|
|
w.Write(data)
|
|
}
|
|
|
|
// register the CodeExchangeHandler at the callbackPath
|
|
// the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function
|
|
// with the returned tokens from the token endpoint
|
|
// in this example the callback function itself is wrapped by the UserinfoCallback which
|
|
// will call the Userinfo endpoint, check the sub and pass the info into the callback function
|
|
http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider))
|
|
|
|
// if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for:
|
|
//
|
|
// http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider))
|
|
|
|
// simple counter for request IDs
|
|
var counter atomic.Int64
|
|
// enable incomming request logging
|
|
mw := logging.Middleware(
|
|
logging.WithLogger(logger),
|
|
logging.WithGroup("server"),
|
|
logging.WithIDFunc(func() slog.Attr {
|
|
return slog.Int64("id", counter.Add(1))
|
|
}),
|
|
)
|
|
|
|
http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return }))
|
|
fmt.Println("/healthy returns 200 OK")
|
|
|
|
server := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: mw(http.DefaultServeMux),
|
|
}
|
|
go func() {
|
|
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("HTTP server error: %v", err)
|
|
}
|
|
log.Println("Stopped serving new connections.")
|
|
}()
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigChan
|
|
|
|
shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer shutdownRelease()
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
log.Fatalf("HTTP shutdown error: %v", err)
|
|
}
|
|
}
|
|
|
|
func readPAT(path string) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
pat, err := io.ReadAll(f)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return strings.Trim(string(pat), "\n")
|
|
}
|
|
|
|
func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) {
|
|
projectID, err := CreateProject(apiURL, pat, domain)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL)
|
|
}
|
|
|
|
type project struct {
|
|
ID string `json:"id"`
|
|
}
|
|
type createProject struct {
|
|
Name string `json:"name"`
|
|
ProjectRoleAssertion bool `json:"projectRoleAssertion"`
|
|
ProjectRoleCheck bool `json:"projectRoleCheck"`
|
|
HasProjectCheck bool `json:"hasProjectCheck"`
|
|
PrivateLabelingSetting string `json:"privateLabelingSetting"`
|
|
}
|
|
|
|
func CreateProject(apiURL, pat, domain string) (string, error) {
|
|
createProject := &createProject{
|
|
Name: "OIDC",
|
|
ProjectRoleAssertion: false,
|
|
ProjectRoleCheck: false,
|
|
HasProjectCheck: false,
|
|
PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED",
|
|
}
|
|
resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
p := new(project)
|
|
if err := json.Unmarshal(data, p); err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Printf("projectID: %+v\n", p.ID)
|
|
return p.ID, nil
|
|
}
|
|
|
|
type createApp struct {
|
|
Name string `json:"name"`
|
|
RedirectUris []string `json:"redirectUris"`
|
|
ResponseTypes []string `json:"responseTypes"`
|
|
GrantTypes []string `json:"grantTypes"`
|
|
AppType string `json:"appType"`
|
|
AuthMethodType string `json:"authMethodType"`
|
|
PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"`
|
|
Version string `json:"version"`
|
|
DevMode bool `json:"devMode"`
|
|
AccessTokenType string `json:"accessTokenType"`
|
|
AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"`
|
|
IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"`
|
|
IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"`
|
|
ClockSkew string `json:"clockSkew"`
|
|
AdditionalOrigins []string `json:"additionalOrigins"`
|
|
SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"`
|
|
BackChannelLogoutUri []string `json:"backChannelLogoutUri"`
|
|
LoginVersion version `json:"loginVersion"`
|
|
}
|
|
|
|
type version struct {
|
|
LoginV2 loginV2 `json:"loginV2"`
|
|
}
|
|
type loginV2 struct {
|
|
BaseUri string `json:"baseUri"`
|
|
}
|
|
|
|
type app struct {
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret"`
|
|
}
|
|
|
|
func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) {
|
|
createApp := &createApp{
|
|
Name: "OIDC",
|
|
RedirectUris: []string{redirectURI},
|
|
ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"},
|
|
GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"},
|
|
AppType: "OIDC_APP_TYPE_WEB",
|
|
AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC",
|
|
Version: "OIDC_VERSION_1_0",
|
|
DevMode: true,
|
|
AccessTokenType: "OIDC_TOKEN_TYPE_BEARER",
|
|
AccessTokenRoleAssertion: true,
|
|
IdTokenRoleAssertion: true,
|
|
IdTokenUserinfoAssertion: true,
|
|
ClockSkew: "1s",
|
|
SkipNativeAppSuccessPage: true,
|
|
LoginVersion: version{
|
|
LoginV2: loginV2{
|
|
BaseUri: loginURL,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp)
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
a := new(app)
|
|
if err := json.Unmarshal(data, a); err != nil {
|
|
return "", "", err
|
|
}
|
|
return a.ClientID, a.ClientSecret, err
|
|
}
|
|
|
|
func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := http.Header{}
|
|
values.Add("Authorization", "Bearer "+pat)
|
|
values.Add("x-forwarded-host", domain)
|
|
values.Add("Content-Type", "application/json")
|
|
req.Header = values
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp, nil
|
|
}
|