Files
zitadel/apps/login/acceptance/oidcrp/main.go
Elio Bischof b10455b51f chore: reproducible pipeline with dev containers (#10305)
# 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
2025-07-24 14:22:32 +02:00

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
}