diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/templates/gitea/init.yaml b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/templates/gitea/init.yaml new file mode 100644 index 0000000..2c30c9f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/templates/gitea/init.yaml @@ -0,0 +1,228 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "gitea.fullname" . }}-init + namespace: {{ .Values.namespace | default .Release.Namespace }} + labels: + {{- include "gitea.labels" . | nindent 4 }} +type: Opaque +stringData: +{{ (.Files.Glob "scripts/init-containers/init/*.sh").AsConfig | indent 2 }} + init_directory_structure.sh: |- + #!/usr/bin/env bash + + set -euo pipefail + + {{- if .Values.initPreScript }} + # BEGIN: initPreScript + {{- with .Values.initPreScript -}} + {{ . | nindent 4}} + {{- end -}} + # END: initPreScript + {{- end }} + + {{- if not .Values.image.rootless }} + chown -v 1000:1000 /data + {{- end }} + mkdir -pv /data/git/.ssh + chmod -Rv 700 /data/git/.ssh + [ ! -d /data/gitea/conf ] && mkdir -pv /data/gitea/conf + + # prepare temp directory structure + mkdir -pv "${GITEA_TEMP}" + {{- if not .Values.image.rootless }} + chown -v 1000:1000 "${GITEA_TEMP}" + {{- end }} + chmod -v ug+rwx "${GITEA_TEMP}" + + {{ if .Values.signing.enabled -}} + if [ ! -d "${GNUPGHOME}" ]; then + mkdir -pv "${GNUPGHOME}" + chmod -v 700 "${GNUPGHOME}" + chown -v 1000:1000 "${GNUPGHOME}" + fi + {{- end }} + + configure_gitea.sh: |- + #!/usr/bin/env bash + + set -euo pipefail + + echo '==== BEGIN GITEA CONFIGURATION ====' + + { # try + gitea migrate + } || { # catch + echo "Gitea migrate might fail due to database connection...This init-container will try again in a few seconds" + exit 1 + } + + {{- if include "valkey.servicename" . }} + function test_valkey_connection() { + local RETRY=0 + local MAX=30 + + echo 'Wait for valkey to become avialable...' + until [ "${RETRY}" -ge "${MAX}" ]; do + nc -vz -w2 {{ include "valkey.servicename" . }} {{ include "valkey.port" . }} && break + RETRY=$[${RETRY}+1] + echo "...not ready yet (${RETRY}/${MAX})" + done + + if [ "${RETRY}" -ge "${MAX}" ]; then + echo "Valkey not reachable after '${MAX}' attempts!" + exit 1 + fi + } + + test_valkey_connection + {{- end }} + + + {{- if or .Values.gitea.admin.existingSecret (and .Values.gitea.admin.username .Values.gitea.admin.password) }} + function configure_admin_user() { + local full_admin_list=$(gitea admin user list --admin) + local actual_user_table='' + + # We might have distorted output due to warning logs, so we have to detect the actual user table by its headline and trim output above that line + local regex="(.*)(ID\s+Username\s+Email\s+IsActive.*)" + if [[ "${full_admin_list}" =~ $regex ]]; then + actual_user_table=$(echo "${BASH_REMATCH[2]}" | tail -n+2) # tail'ing to drop the table headline + else + # This code block should never be reached, as long as the output table header remains the same. + # If this code block is reached, the regex doesn't match anymore and we probably have to adjust this script. + + echo "ERROR: 'configure_admin_user' was not able to determine the current list of admin users." + echo " Please review the output of 'gitea admin user list --admin' shown below." + echo " If you think it is an issue with the Helm Chart provisioning, file an issue at https://gitea.com/gitea/helm-gitea/issues." + echo "DEBUG: Output of 'gitea admin user list --admin'" + echo "--" + echo "${full_admin_list}" + echo "--" + exit 1 + fi + + local ACCOUNT_ID=$(echo "${actual_user_table}" | grep -E "\s+${GITEA_ADMIN_USERNAME}\s+" | awk -F " " "{printf \$1}") + if [[ -z "${ACCOUNT_ID}" ]]; then + local -a create_args + create_args=(--admin --username "${GITEA_ADMIN_USERNAME}" --password "${GITEA_ADMIN_PASSWORD}" --email {{ .Values.gitea.admin.email | quote }}) + if [[ "${GITEA_ADMIN_PASSWORD_MODE}" = initialOnlyRequireReset ]]; then + create_args+=(--must-change-password=true) + else + create_args+=(--must-change-password=false) + fi + echo "No admin user '${GITEA_ADMIN_USERNAME}' found. Creating now..." + gitea admin user create "${create_args[@]}" + echo '...created.' + else + if [[ "${GITEA_ADMIN_PASSWORD_MODE}" = keepUpdated ]]; then + echo "Admin account '${GITEA_ADMIN_USERNAME}' already exist. Running update to sync password..." + # See https://gitea.com/gitea/helm-gitea/issues/673 + # --must-change-password argument was added to change-password, defaulting to true, counter to the previous behavior + # which acted as if it were provided with =false. If the argument is present in this version of gitea, then we + # should add it to prevent requiring frequent admin password resets. + local -a change_args + change_args=(--username "${GITEA_ADMIN_USERNAME}" --password "${GITEA_ADMIN_PASSWORD}") + if gitea admin user change-password --help | grep -qF -- '--must-change-password'; then + change_args+=(--must-change-password=false) + fi + gitea admin user change-password "${change_args[@]}" + echo '...password sync done.' + else + echo "Admin account '${GITEA_ADMIN_USERNAME}' already exist, but update mode is set to '${GITEA_ADMIN_PASSWORD_MODE}'. Skipping." + fi + fi + } + + configure_admin_user + {{- end }} + + function configure_ldap() { + {{- if .Values.gitea.ldap }} + {{- range $idx, $value := .Values.gitea.ldap }} + local LDAP_NAME={{ (printf "%s" $value.name) | squote }} + local full_auth_list=$(gitea admin auth list --vertical-bars) + local actual_auth_table='' + + # We might have distorted output due to warning logs, so we have to detect the actual user table by its headline and trim output above that line + local regex="(.*)(ID\s+\|Name\s+\|Type\s+\|Enabled.*)" + if [[ "${full_auth_list}" =~ $regex ]]; then + actual_auth_table=$(echo "${BASH_REMATCH[2]}" | tail -n+2) # tail'ing to drop the table headline + else + # This code block should never be reached, as long as the output table header remains the same. + # If this code block is reached, the regex doesn't match anymore and we probably have to adjust this script. + + echo "ERROR: 'configure_ldap' was not able to determine the current list of authentication sources." + echo " Please review the output of 'gitea admin auth list --vertical-bars' shown below." + echo " If you think it is an issue with the Helm Chart provisioning, file an issue at https://gitea.com/gitea/helm-gitea/issues." + echo "DEBUG: Output of 'gitea admin auth list --vertical-bars'" + echo "--" + echo "${full_auth_list}" + echo "--" + exit 1 + fi + + local GITEA_AUTH_ID=$(echo "${actual_auth_table}" | grep -E "\|${LDAP_NAME}\s+\|" | grep -iE '\|LDAP \(via BindDN\)\s+\|' | awk -F " " "{print \$1}") + + if [[ -z "${GITEA_AUTH_ID}" ]]; then + echo "No ldap configuration found with name '${LDAP_NAME}'. Installing it now..." + gitea admin auth add-ldap {{- include "gitea.ldap_settings" (list $idx $value) | indent 1 }} + echo '...installed.' + else + echo "Existing ldap configuration with name '${LDAP_NAME}': '${GITEA_AUTH_ID}'. Running update to sync settings..." + gitea admin auth update-ldap --id "${GITEA_AUTH_ID}" {{- include "gitea.ldap_settings" (list $idx $value) | indent 1 }} + echo '...sync settings done.' + fi + {{- end }} + {{- else }} + echo 'no ldap configuration... skipping.' + {{- end }} + } + + configure_ldap + + function configure_oauth() { + {{- if .Values.gitea.oauth }} + {{- range $idx, $value := .Values.gitea.oauth }} + local OAUTH_NAME={{ (printf "%s" $value.name) | squote }} + local full_auth_list=$(gitea admin auth list --vertical-bars) + local actual_auth_table='' + + # We might have distorted output due to warning logs, so we have to detect the actual user table by its headline and trim output above that line + local regex="(.*)(ID\s+\|Name\s+\|Type\s+\|Enabled.*)" + if [[ "${full_auth_list}" =~ $regex ]]; then + actual_auth_table=$(echo "${BASH_REMATCH[2]}" | tail -n+2) # tail'ing to drop the table headline + else + # This code block should never be reached, as long as the output table header remains the same. + # If this code block is reached, the regex doesn't match anymore and we probably have to adjust this script. + + echo "ERROR: 'configure_oauth' was not able to determine the current list of authentication sources." + echo " Please review the output of 'gitea admin auth list --vertical-bars' shown below." + echo " If you think it is an issue with the Helm Chart provisioning, file an issue at https://gitea.com/gitea/helm-gitea/issues." + echo "DEBUG: Output of 'gitea admin auth list --vertical-bars'" + echo "--" + echo "${full_auth_list}" + echo "--" + exit 1 + fi + + local AUTH_ID=$(echo "${actual_auth_table}" | grep -E "\|${OAUTH_NAME}\s+\|" | grep -iE '\|OAuth2\s+\|' | awk -F " " "{print \$1}") + + if [[ -z "${AUTH_ID}" ]]; then + echo "No oauth configuration found with name '${OAUTH_NAME}'. Installing it now..." + gitea admin auth add-oauth {{- include "gitea.oauth_settings" (list $idx $value) | indent 1 }} + echo '...installed.' + else + echo "Existing oauth configuration with name '${OAUTH_NAME}': '${AUTH_ID}'. Running update to sync settings..." + gitea admin auth update-oauth --id "${AUTH_ID}" {{- include "gitea.oauth_settings" (list $idx $value) | indent 1 }} + echo '...sync settings done.' + fi + {{- end }} + {{- else }} + echo 'no oauth configuration... skipping.' + {{- end }} + } + + configure_oauth + + echo '==== END GITEA CONFIGURATION ====' \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/values.yaml b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/values.yaml new file mode 100644 index 0000000..107e623 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-12.5.0/values.yaml @@ -0,0 +1,717 @@ +# Default values for gitea. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +## @section Global +# +## @param global.imageRegistry global image registry override +## @param global.imagePullSecrets global image pull secrets override; can be extended by `imagePullSecrets` +## @param global.storageClass global storage class override +## @param global.hostAliases global hostAliases which will be added to the pod's hosts files +global: + imageRegistry: "" + ## E.g. + ## imagePullSecrets: + ## - myRegistryKeySecretName + ## + imagePullSecrets: [] + storageClass: "" + hostAliases: [] + # - ip: 192.168.137.2 + # hostnames: + # - example.com + +## @param namespace An explicit namespace to deploy gitea into. Defaults to the release namespace if not specified +namespace: "" + +## @param replicaCount number of replicas for the deployment +replicaCount: 1 + +## @section strategy +## @param strategy.type strategy type +## @param strategy.rollingUpdate.maxSurge maxSurge +## @param strategy.rollingUpdate.maxUnavailable maxUnavailable +strategy: + type: "RollingUpdate" + rollingUpdate: + maxSurge: "100%" + maxUnavailable: 0 + +## @param clusterDomain cluster domain +clusterDomain: cluster.local + +## @section Image +## @param image.registry image registry, e.g. gcr.io,docker.io +## @param image.repository Image to start for this pod +## @param image.tag Visit: [Image tag](https://hub.docker.com/r/gitea/gitea/tags?page=1&ordering=last_updated). Defaults to `appVersion` within Chart.yaml. +## @param image.digest Image digest. Allows to pin the given image tag. Useful for having control over mutable tags like `latest` +## @param image.pullPolicy Image pull policy +## @param image.rootless Wether or not to pull the rootless version of Gitea, only works on Gitea 1.14.x or higher +## @param image.fullOverride Completely overrides the image registry, path/image, tag and digest. **Adjust `image.rootless` accordingly and review [Rootless defaults](#rootless-defaults).** +image: + registry: "docker.gitea.com" + repository: gitea + # Overrides the image tag whose default is the chart appVersion. + tag: "" + digest: "" + pullPolicy: IfNotPresent + rootless: true + fullOverride: "" + +## @param imagePullSecrets Secret to use for pulling the image +imagePullSecrets: [] + +## @section Security +# Security context is only usable with rootless image due to image design +## @param podSecurityContext.fsGroup Set the shared file system group for all containers in the pod. +podSecurityContext: + fsGroup: 1000 + +## @param containerSecurityContext Security context +containerSecurityContext: {} +# allowPrivilegeEscalation: false +# capabilities: +# drop: +# - ALL +# # Add the SYS_CHROOT capability for root and rootless images if you intend to +# # run pods on nodes that use the container runtime cri-o. Otherwise, you will +# # get an error message from the SSH server that it is not possible to read from +# # the repository. +# # https://gitea.com/gitea/helm-gitea/issues/161 +# add: +# - SYS_CHROOT +# privileged: false +# readOnlyRootFilesystem: true +# runAsGroup: 1000 +# runAsNonRoot: true +# runAsUser: 1000 + +## @deprecated The securityContext variable has been split two: +## - containerSecurityContext +## - podSecurityContext. +## @param securityContext Run init and Gitea containers as a specific securityContext +securityContext: {} + +## @param podDisruptionBudget Pod disruption budget +podDisruptionBudget: {} +# maxUnavailable: 1 +# minAvailable: 1 + +## @section Service +service: + ## @param service.http.type Kubernetes service type for web traffic + ## @param service.http.port Port number for web traffic + ## @param service.http.clusterIP ClusterIP setting for http autosetup for deployment is None + ## @param service.http.loadBalancerIP LoadBalancer IP setting + ## @param service.http.nodePort NodePort for http service + ## @param service.http.externalTrafficPolicy If `service.http.type` is `NodePort` or `LoadBalancer`, set this to `Local` to enable source IP preservation + ## @param service.http.externalIPs External IPs for service + ## @param service.http.ipFamilyPolicy HTTP service dual-stack policy + ## @param service.http.ipFamilies HTTP service dual-stack familiy selection,for dual-stack parameters see official kubernetes [dual-stack concept documentation](https://kubernetes.io/docs/concepts/services-networking/dual-stack/). + ## @param service.http.loadBalancerSourceRanges Source range filter for http loadbalancer + ## @param service.http.annotations HTTP service annotations + ## @param service.http.labels HTTP service additional labels + ## @param service.http.loadBalancerClass Loadbalancer class + http: + type: ClusterIP + port: 3000 + clusterIP: None + loadBalancerIP: + nodePort: + externalTrafficPolicy: + externalIPs: + ipFamilyPolicy: + ipFamilies: + loadBalancerSourceRanges: [] + annotations: {} + labels: {} + loadBalancerClass: + ## @param service.ssh.type Kubernetes service type for ssh traffic + ## @param service.ssh.port Port number for ssh traffic + ## @param service.ssh.clusterIP ClusterIP setting for ssh autosetup for deployment is None + ## @param service.ssh.loadBalancerIP LoadBalancer IP setting + ## @param service.ssh.nodePort NodePort for ssh service + ## @param service.ssh.externalTrafficPolicy If `service.ssh.type` is `NodePort` or `LoadBalancer`, set this to `Local` to enable source IP preservation + ## @param service.ssh.externalIPs External IPs for service + ## @param service.ssh.ipFamilyPolicy SSH service dual-stack policy + ## @param service.ssh.ipFamilies SSH service dual-stack familiy selection,for dual-stack parameters see official kubernetes [dual-stack concept documentation](https://kubernetes.io/docs/concepts/services-networking/dual-stack/). + ## @param service.ssh.hostPort HostPort for ssh service + ## @param service.ssh.loadBalancerSourceRanges Source range filter for ssh loadbalancer + ## @param service.ssh.annotations SSH service annotations + ## @param service.ssh.labels SSH service additional labels + ## @param service.ssh.loadBalancerClass Loadbalancer class + ssh: + type: ClusterIP + port: 22 + clusterIP: None + loadBalancerIP: + nodePort: + externalTrafficPolicy: + externalIPs: + ipFamilyPolicy: + ipFamilies: + hostPort: + loadBalancerSourceRanges: [] + annotations: {} + labels: {} + loadBalancerClass: + +## @section Ingress +## @param ingress.enabled Enable ingress +## @param ingress.className DEPRECATED: Ingress class name. +## @param ingress.pathType Ingress Path Type +## @param ingress.annotations Ingress annotations +## @param ingress.hosts[0].host Default Ingress host +## @param ingress.hosts[0].paths[0].path Default Ingress path +## @param ingress.tls Ingress tls settings +ingress: + enabled: false + className: "" + pathType: Prefix + annotations: {} + hosts: + - host: git.example.com + paths: + - path: / + tls: [] + # - secretName: chart-example-tls + # hosts: + # - git.example.com + +## @section deployment +# +## @param resources Kubernetes resources +resources: + {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +## Use an alternate scheduler, e.g. "stork". +## ref: https://kubernetes.io/docs/tasks/administer-cluster/configure-multiple-schedulers/ +## +## @param schedulerName Use an alternate scheduler, e.g. "stork" +schedulerName: "" + +## @param nodeSelector NodeSelector for the deployment +nodeSelector: {} + +## @param tolerations Tolerations for the deployment +tolerations: [] + +## @param affinity Affinity for the deployment +affinity: {} + +## @param topologySpreadConstraints TopologySpreadConstraints for the deployment +topologySpreadConstraints: [] + +## @param dnsConfig dnsConfig for the deployment +dnsConfig: {} + +## @param priorityClassName priorityClassName for the deployment +priorityClassName: "" + +## @param deployment.env Additional environment variables to pass to containers +## @param deployment.terminationGracePeriodSeconds How long to wait until forcefully kill the pod +## @param deployment.labels Labels for the deployment +## @param deployment.annotations Annotations for the Gitea deployment to be created +deployment: + env: + [] + # - name: VARIABLE + # value: my-value + terminationGracePeriodSeconds: 60 + labels: {} + annotations: {} + +## @section ServiceAccount + +## @param serviceAccount.create Enable the creation of a ServiceAccount +## @param serviceAccount.name Name of the created ServiceAccount, defaults to release name. Can also link to an externally provided ServiceAccount that should be used. +## @param serviceAccount.automountServiceAccountToken Enable/disable auto mounting of the service account token +## @param serviceAccount.imagePullSecrets Image pull secrets, available to the ServiceAccount +## @param serviceAccount.annotations Custom annotations for the ServiceAccount +## @param serviceAccount.labels Custom labels for the ServiceAccount +serviceAccount: + create: false + name: "" + automountServiceAccountToken: false + imagePullSecrets: [] + # - name: private-registry-access + annotations: {} + labels: {} + +## @section Persistence +# +## @param persistence.enabled Enable persistent storage +## @param persistence.create Whether to create the persistentVolumeClaim for shared storage +## @param persistence.mount Whether the persistentVolumeClaim should be mounted (even if not created) +## @param persistence.claimName Use an existing claim to store repository information +## @param persistence.size Size for persistence to store repo information +## @param persistence.accessModes AccessMode for persistence +## @param persistence.labels Labels for the persistence volume claim to be created +## @param persistence.annotations.helm.sh/resource-policy Resource policy for the persistence volume claim +## @param persistence.storageClass Name of the storage class to use +## @param persistence.subPath Subdirectory of the volume to mount at +## @param persistence.volumeName Name of persistent volume in PVC +persistence: + enabled: true + create: true + mount: true + claimName: gitea-shared-storage + size: 10Gi + accessModes: + - ReadWriteOnce + labels: {} + storageClass: + subPath: + volumeName: "" + annotations: + helm.sh/resource-policy: keep + +## @param extraContainers Additional sidecar containers to run in the pod +extraContainers: [] +# - name: sidecar-bob +# image: busybox +# command: [/bin/sh, -c, 'echo "Hello world"'] + +## @param preExtraInitContainers Additional init containers to run in the pod before gitea runs it owns init containers. +preExtraInitContainers: [] +# - name: pre-init-container +# image: docker.io/library/busybox +# command: [ /bin/sh, -c, 'echo "Hello world! I am a pre init container."' ] + +## @param postExtraInitContainers Additional init containers to run in the pod after gitea runs it owns init containers. +postExtraInitContainers: [] +# - name: post-init-container +# image: docker.io/library/busybox +# command: [ /bin/sh, -c, 'echo "Hello world! I am a post init container."' ] + +## @param extraVolumes Additional volumes to mount to the Gitea deployment +extraVolumes: [] +# - name: postgres-ssl-vol +# secret: +# secretName: gitea-postgres-ssl + +## @param extraContainerVolumeMounts Mounts that are only mapped into the Gitea runtime/main container, to e.g. override custom templates. +extraContainerVolumeMounts: [] + +## @param extraInitVolumeMounts Mounts that are only mapped into the init-containers. Can be used for additional preconfiguration. +extraInitVolumeMounts: [] + +## @deprecated The extraVolumeMounts variable has been split two: +## - extraContainerVolumeMounts +## - extraInitVolumeMounts +## As an example, can be used to mount a client cert when connecting to an external Postgres server. +## @param extraVolumeMounts **DEPRECATED** Additional volume mounts for init containers and the Gitea main container +extraVolumeMounts: [] +# - name: postgres-ssl-vol +# readOnly: true +# mountPath: "/pg-ssl" + +## @section Init +## @param initPreScript Bash shell script copied verbatim to the start of the init-container. +initPreScript: "" +## @param initContainersScriptsVolumeMountPath Path to mount the scripts consumed from the Secrets +initContainersScriptsVolumeMountPath: "/usr/sbinx" +# +# initPreScript: | +# mkdir -p /data/git/.postgresql +# cp /pg-ssl/* /data/git/.postgresql/ +# chown -R git:git /data/git/.postgresql/ +# chmod 400 /data/git/.postgresql/postgresql.key + +## @param initContainers.resources.limits initContainers.limits Kubernetes resource limits for init containers +## @param initContainers.resources.requests.cpu initContainers.requests.cpu Kubernetes cpu resource limits for init containers +## @param initContainers.resources.requests.memory initContainers.requests.memory Kubernetes memory resource limits for init containers +initContainers: + resources: + limits: {} + requests: + cpu: 100m + memory: 128Mi + +# Configure commit/action signing prerequisites +## @section Signing +# +## @param signing.enabled Enable commit/action signing +## @param signing.gpgHome GPG home directory +## @param signing.privateKey Inline private gpg key for signed internal Git activity +## @param signing.existingSecret Use an existing secret to store the value of `signing.privateKey` +signing: + enabled: false + gpgHome: /data/git/.gnupg + privateKey: "" + # privateKey: |- + # -----BEGIN PGP PRIVATE KEY BLOCK----- + # ... + # -----END PGP PRIVATE KEY BLOCK----- + existingSecret: "" + +## @section Gitea +# +gitea: + ## @param gitea.admin.username Username for the Gitea admin user + ## @param gitea.admin.existingSecret Use an existing secret to store admin user credentials + ## @param gitea.admin.password Password for the Gitea admin user + ## @param gitea.admin.email Email for the Gitea admin user + ## @param gitea.admin.passwordMode Mode for how to set/update the admin user password. Options are: initialOnlyNoReset, initialOnlyRequireReset, and keepUpdated + admin: + # existingSecret: gitea-admin-secret + existingSecret: + username: gitea_admin + password: r8sA8CPHD9!bt6d + email: "gitea@local.domain" + passwordMode: keepUpdated + + ## @param gitea.metrics.enabled Enable Gitea metrics + ## @param gitea.metrics.token used for `bearer` token authentication on metrics endpoint. If not specified or empty metrics endpoint is public. + ## @param gitea.metrics.serviceMonitor.enabled Enable Gitea metrics service monitor. Requires, that `gitea.metrics.enabled` is also set to true, to enable metrics generally. + ## @param gitea.metrics.serviceMonitor.interval Interval at which metrics should be scraped. If not specified Prometheus' global scrape interval is used. + ## @param gitea.metrics.serviceMonitor.relabelings RelabelConfigs to apply to samples before scraping. + ## @param gitea.metrics.serviceMonitor.scheme HTTP scheme to use for scraping. For example `http` or `https`. Default is http. + ## @param gitea.metrics.serviceMonitor.scrapeTimeout Timeout after which the scrape is ended. If not specified, global Prometheus scrape timeout is used. + ## @param gitea.metrics.serviceMonitor.tlsConfig TLS configuration to use when scraping the metric endpoint by Prometheus. + metrics: + enabled: false + token: + serviceMonitor: + enabled: false + # additionalLabels: + # prometheus-release: prom1 + interval: "" + relabelings: [] + scheme: "" + scrapeTimeout: "" + tlsConfig: {} + + ## @param gitea.ldap LDAP configuration + ldap: + [] + # - name: "LDAP 1" + # existingSecret: + # securityProtocol: + # host: + # port: + # userSearchBase: + # userFilter: + # adminFilter: + # emailAttribute: + # bindDn: + # bindPassword: + # usernameAttribute: + # publicSSHKeyAttribute: + + # Either specify inline `key` and `secret` or refer to them via `existingSecret` + ## @param gitea.oauth OAuth configuration + oauth: + [] + # - name: 'OAuth 1' + # provider: + # key: + # secret: + # existingSecret: + # autoDiscoverUrl: + # useCustomUrls: + # customAuthUrl: + # customTokenUrl: + # customProfileUrl: + # customEmailUrl: + + ## @param gitea.config.server.SSH_PORT SSH port for rootlful Gitea image + ## @param gitea.config.server.SSH_LISTEN_PORT SSH port for rootless Gitea image + config: + # APP_NAME: "Gitea: Git with a cup of tea" + # RUN_MODE: dev + server: + SSH_PORT: 22 # rootful image + SSH_LISTEN_PORT: 2222 # rootless image + # + # security: + # PASSWORD_COMPLEXITY: spec + + ## @param gitea.additionalConfigSources Additional configuration from secret or configmap + additionalConfigSources: [] + # - secret: + # secretName: gitea-app-ini-oauth + # - configMap: + # name: gitea-app-ini-plaintext + + ## @param gitea.additionalConfigFromEnvs Additional configuration sources from environment variables + additionalConfigFromEnvs: [] + + ## @param gitea.podAnnotations Annotations for the Gitea pod + podAnnotations: {} + + ## @param gitea.ssh.logLevel Configure OpenSSH's log level. Only available for root-based Gitea image. + ssh: + logLevel: "INFO" + + ## @section LivenessProbe + # + ## @param gitea.livenessProbe.enabled Enable liveness probe + ## @param gitea.livenessProbe.tcpSocket.port Port to probe for liveness + ## @param gitea.livenessProbe.initialDelaySeconds Initial delay before liveness probe is initiated + ## @param gitea.livenessProbe.timeoutSeconds Timeout for liveness probe + ## @param gitea.livenessProbe.periodSeconds Period for liveness probe + ## @param gitea.livenessProbe.successThreshold Success threshold for liveness probe + ## @param gitea.livenessProbe.failureThreshold Failure threshold for liveness probe + # Modify the liveness probe for your needs or completely disable it by commenting out. + livenessProbe: + enabled: true + tcpSocket: + port: http + initialDelaySeconds: 200 + timeoutSeconds: 1 + periodSeconds: 10 + successThreshold: 1 + failureThreshold: 10 + + ## @section ReadinessProbe + # + ## @param gitea.readinessProbe.enabled Enable readiness probe + ## @param gitea.readinessProbe.tcpSocket.port Port to probe for readiness + ## @param gitea.readinessProbe.initialDelaySeconds Initial delay before readiness probe is initiated + ## @param gitea.readinessProbe.timeoutSeconds Timeout for readiness probe + ## @param gitea.readinessProbe.periodSeconds Period for readiness probe + ## @param gitea.readinessProbe.successThreshold Success threshold for readiness probe + ## @param gitea.readinessProbe.failureThreshold Failure threshold for readiness probe + # Modify the readiness probe for your needs or completely disable it by commenting out. + readinessProbe: + enabled: true + tcpSocket: + port: http + initialDelaySeconds: 5 + timeoutSeconds: 1 + periodSeconds: 10 + successThreshold: 1 + failureThreshold: 3 + + # # Uncomment the startup probe to enable and modify it for your needs. + ## @section StartupProbe + # + ## @param gitea.startupProbe.enabled Enable startup probe + ## @param gitea.startupProbe.tcpSocket.port Port to probe for startup + ## @param gitea.startupProbe.initialDelaySeconds Initial delay before startup probe is initiated + ## @param gitea.startupProbe.timeoutSeconds Timeout for startup probe + ## @param gitea.startupProbe.periodSeconds Period for startup probe + ## @param gitea.startupProbe.successThreshold Success threshold for startup probe + ## @param gitea.startupProbe.failureThreshold Failure threshold for startup probe + startupProbe: + enabled: false + tcpSocket: + port: http + initialDelaySeconds: 60 + timeoutSeconds: 1 + periodSeconds: 10 + successThreshold: 1 + failureThreshold: 10 + +## @section valkey-cluster +## @descriptionStart +## Valkey cluster and [Valkey](#valkey) cannot be enabled at the same time. +## @descriptionEnd +valkey-cluster: + ## @param valkey-cluster.enabled Enable valkey cluster + # ⚠️ The valkey charts do not work well with special characters in the password (). + # Consider omitting such or open an issue in the Bitnami repo and let us know once this got fixed. + ## @param valkey-cluster.usePassword Whether to use password authentication. + ## @param valkey-cluster.usePasswordFiles Whether to mount passwords as files instead of environment variables. + enabled: true + usePassword: false + usePasswordFiles: false + + ## @param valkey-cluster.image.repository Image repository, eg. `bitnamilegacy/valkey-cluster`. + image: + repository: bitnamilegacy/valkey-cluster + + ## @param valkey-cluster.cluster.nodes Number of valkey cluster master nodes + ## @param valkey-cluster.cluster.replicas Number of valkey cluster master node replicas + cluster: + nodes: 3 # default: 6 + replicas: 0 # default: 1 + + ## @param valkey-cluster.metrics.image.repository Image repository, eg. `bitnamilegacy/redis-exporter`. + metrics: + image: + repository: bitnamilegacy/redis-exporter + + ## @param valkey-cluster.service.ports.valkey Port of Valkey service + service: + ports: + valkey: 6379 + + ## @param valkey-cluster.sysctlImage.repository Image repository, eg. `bitnamilegacy/os-shell`. + sysctlImage: + repository: bitnamilegacy/os-shell + + ## @param valkey-cluster.volumePermissions.image.repository Image repository, eg. `bitnamilegacy/os-shell`. + volumePermissions: + image: + repository: bitnamilegacy/os-shell + + +## @section valkey + +## @descriptionStart +## Valkey and [Valkey cluster](#valkey-cluster) cannot be enabled at the same time. +## @descriptionEnd +valkey: + ## @param valkey.enabled Enable valkey standalone or replicated + ## @param valkey.architecture Whether to use standalone or replication + enabled: false + architecture: standalone + + ## @param valkey.kubectl.image.repository Image repository, eg. `bitnamilegacy/kubectl`. + kubectl: + image: + repository: bitnamilegacy/kubectl + + ## @param valkey.image.repository Image repository, eg. `bitnamilegacy/valkey`. + image: + repository: bitnamilegacy/valkey + + # ⚠️ The valkey charts do not work well with special characters in the password (). + # Consider omitting such or open an issue in the Bitnami repo and let us know once this got fixed. + ## @param valkey.global.valkey.password Required password + global: + valkey: + password: changeme + + ## @param valkey.master.count Number of Valkey master instances to deploy + ## @param valkey.master.service.ports.valkey Port of Valkey service + master: + count: 1 + service: + ports: + valkey: 6379 + + ## @param valkey.metrics.image.repository Image repository, eg. `bitnamilegacy/redis-exporter`. + metrics: + image: + repository: bitnamilegacy/redis-exporter + + ## @param valkey.sentinel.image.repository Image repository, eg. `bitnamilegacy/sentinel`. + sentinel: + image: + repository: bitnamilegacy/valkey-sentinel + + ## @param valkey.volumePermissions.image.repository Image repository, eg. `bitnamilegacy/os-shell`. + volumePermissions: + image: + repository: bitnamilegacy/os-shell + +## @section PostgreSQL HA +postgresql-ha: + ## @param postgresql-ha.enabled Enable PostgreSQL HA + enabled: true + + ## @param postgresql-ha.global.postgresql.database Name for a custom database to create (overrides `auth.database`) + ## @param postgresql-ha.global.postgresql.username Name for a custom user to create (overrides `auth.username`) + ## @param postgresql-ha.global.postgresql.password Name for a custom password to create (overrides `auth.password`) + global: + postgresql: + database: gitea + password: gitea + username: gitea + + ## @param postgresql-ha.metrics.image.repository Image repository, eg. `bitnamilegacy/postgres-exporter`. + metrics: + image: + repository: bitnamilegacy/postgres-exporter + + ## @param postgresql-ha.postgresql.image.repository Image repository, eg. `bitnamilegacy/postgresql-repmgr`. + ## @param postgresql-ha.postgresql.repmgrPassword Repmgr Password + ## @param postgresql-ha.postgresql.postgresPassword postgres Password + ## @param postgresql-ha.postgresql.password Password for the `gitea` user (overrides `auth.password`) + postgresql: + image: + repository: bitnamilegacy/postgresql-repmgr + repmgrPassword: changeme2 + postgresPassword: changeme1 + password: changeme4 + + ## @param postgresql-ha.pgpool.adminPassword pgpool adminPassword + ## @param postgresql-ha.pgpool.image.repository Image repository, eg. `bitnamilegacy/pgpool`. + ## @param postgresql-ha.pgpool.srCheckPassword pgpool srCheckPassword + pgpool: + adminPassword: changeme3 + image: + repository: bitnamilegacy/pgpool + srCheckPassword: changeme4 + + ## @param postgresql-ha.service.ports.postgresql PostgreSQL service port (overrides `service.ports.postgresql`) + service: + ports: + postgresql: 5432 + + ## @param postgresql-ha.persistence.size PVC Storage Request for PostgreSQL HA volume + persistence: + size: 10Gi + + ## @param postgresql-ha.volumePermissions.image.repository Image repository, eg. `bitnamilegacy/os-shell`. + volumePermissions: + image: + repository: bitnamilegacy/os-shell + +## @section PostgreSQL +postgresql: + ## @param postgresql.enabled Enable PostgreSQL + enabled: false + + ## @param postgresql.global.postgresql.auth.password Password for the `gitea` user (overrides `auth.password`) + ## @param postgresql.global.postgresql.auth.database Name for a custom database to create (overrides `auth.database`) + ## @param postgresql.global.postgresql.auth.username Name for a custom user to create (overrides `auth.username`) + ## @param postgresql.global.postgresql.service.ports.postgresql PostgreSQL service port (overrides `service.ports.postgresql`) + global: + postgresql: + auth: + password: gitea + database: gitea + username: gitea + service: + ports: + postgresql: 5432 + + ## @param postgresql.image.repository Image repository, eg. `bitnamilegacy/postgresql`. + image: + repository: bitnamilegacy/postgresql + + ## @param postgresql.primary.persistence.size PVC Storage Request for PostgreSQL volume + primary: + persistence: + size: 10Gi + + ## @param postgresql.metrics.image.repository Image repository, eg. `bitnamilegacy/postgres-exporter`. + metrics: + image: + repository: bitnamilegacy/postgres-exporter + + ## @param postgresql.volumePermissions.image.repository Image repository, eg. `bitnamilegacy/os-shell`. + volumePermissions: + image: + repository: bitnamilegacy/os-shell + +# By default, removed or moved settings that still remain in a user defined values.yaml will cause Helm to fail running the install/update. +# Set it to false to skip this basic validation check. +## @section Advanced +## @param checkDeprecation Set it to false to skip this basic validation check. +## @param test.enabled Set it to false to disable test-connection Pod. +## @param test.image.name Image name for the wget container used in the test-connection Pod. +## @param test.image.tag Image tag for the wget container used in the test-connection Pod. +checkDeprecation: true +test: + enabled: true + image: + name: busybox + tag: latest + +## @param extraDeploy Array of extra objects to deploy with the release +## +extraDeploy: [] diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-index-entry.json b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-index-entry.json new file mode 100644 index 0000000..138096f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/chart-index-entry.json @@ -0,0 +1,75 @@ +{ + "annotations": { + "artifacthub.io/changes": "- kind: changed\n description: update dependency go-gitea/gitea to v1.25.4 (#1012)\n- kind: fixed\n description: remove deployment.labels from selector.matchLabels (#1011)\n- kind: changed\n description: update lockfiles (#1009)\n- kind: changed\n description: update lockfiles (#1008)\n- kind: changed\n description: update lockfiles (#1006)\n- kind: changed\n description: update unittests/bash/test_helper/bats-mock digest to db2acdc (#1002)\n- kind: changed\n description: update unittests/bash/test_helper/bats-mock digest to c5674ff (#1000)\n- kind: changed\n description: update dependency go-gitea/gitea to v1.25.3 (#999)\n- kind: changed\n description: update dependency markdownlint-cli to ^0.47.0 (#998)\n- kind: changed\n description: update unittests/bash/test_helper/bats-mock digest to 1ce6b03 (#997)\n- kind: changed\n description: update lockfiles (#996)\n- kind: changed\n description: update workflow dependencies (minor & patch) (#995)\n- kind: changed\n description: update actions/checkout action to v6 (#989)\n- kind: changed\n description: update lockfiles (#994)\n- kind: changed\n description: update bats testing framework (#992)\n- kind: changed\n description: update dependency go-gitea/gitea to v1.25.2 (#990)\n- kind: changed\n description: update dependency markdownlint-cli to ^0.46.0 (#988)\n- kind: changed\n description: update unittests/bash/test_helper/bats-mock digest to 7839917 (#987)\n- kind: changed\n description: update lockfiles (#986)\n- kind: changed\n description: update lockfiles (#985)\n- kind: changed\n description: update docker.io/thegeeklab/git-sv docker tag to v2.0.7 (#984)\n- kind: changed\n description: update bats testing framework (#983)\n- kind: changed\n description: update dependency go-gitea/gitea to v1.25.1 (#981)\n- kind: fixed\n description: add yamllint as dedicated target\n- kind: changed\n description: add missing new line\n- kind: fixed\n description: 'avoid duplicated nodeSelector #980'\n- kind: changed\n description: update unittests/bash/bats digest to f423e84 (#978)\n- kind: changed\n description: update dependency go-gitea/gitea to v1.25.0 (#976)\n- kind: changed\n description: update dependency go-gitea/gitea to v1.24.7 (#975)\n- kind: changed\n description: update docker.io/thegeeklab/git-sv docker tag to v2.0.6 (#974)\n- kind: changed\n description: update bats testing framework (#967)\n- kind: changed\n description: update lockfiles (#970)\n- kind: changed\n description: update lockfiles (#969)\n- kind: fixed\n description: add annotation 'artifacthub.io/links'\n- kind: changed\n description: update dependency helm-unittest/helm-unittest to v1.0.3 (#964)\n- kind: changed\n description: update lockfiles (#961)\n", + "artifacthub.io/links": "- name: support\n url: https://gitea.com/gitea/helm-gitea/issues\n" + }, + "apiVersion": "v2", + "appVersion": "1.25.4", + "created": "2026-01-23T20:13:36.765450965Z", + "dependencies": [ + { + "condition": "postgresql.enabled", + "name": "postgresql", + "repository": "oci://registry-1.docker.io/bitnamicharts", + "version": "16.7.27" + }, + { + "condition": "postgresql-ha.enabled", + "name": "postgresql-ha", + "repository": "oci://registry-1.docker.io/bitnamicharts", + "version": "16.3.2" + }, + { + "condition": "valkey-cluster.enabled", + "name": "valkey-cluster", + "repository": "oci://registry-1.docker.io/bitnamicharts", + "version": "3.0.24" + }, + { + "condition": "valkey.enabled", + "name": "valkey", + "repository": "oci://registry-1.docker.io/bitnamicharts", + "version": "3.0.31" + } + ], + "description": "Gitea Helm chart for Kubernetes", + "digest": "2d6f35f3d1ff0cfcab0f22142d20a1d6405fa974a79d7a18000904d233131823", + "icon": "https://gitea.com/assets/img/logo.svg", + "keywords": [ + "git", + "issue tracker", + "code review", + "wiki", + "gitea", + "gogs" + ], + "maintainers": [ + { + "email": "ross@golder.org", + "name": "Ross Golder" + }, + { + "email": "markus.pesch+apps@cryptic.systems", + "name": "Markus Pesch" + }, + { + "email": "dselen@nerthus.nl", + "name": "Daan Selen" + }, + { + "email": "christopher.homberger@web.de", + "name": "Christopher Homberger" + } + ], + "name": "gitea", + "sources": [ + "https://gitea.com/gitea/helm-gitea", + "https://github.com/go-gitea/gitea", + "https://docker.gitea.com/gitea" + ], + "type": "application", + "urls": [ + "https://dl.gitea.com/charts/gitea-12.5.0.tgz" + ], + "version": "12.5.0" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/cmd/admin_auth_oauth.go b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/cmd/admin_auth_oauth.go new file mode 100644 index 0000000..5823879 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/cmd/admin_auth_oauth.go @@ -0,0 +1,299 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + "fmt" + "net/url" + + auth_model "forgejo.org/models/auth" + "forgejo.org/services/auth/source/oauth2" + + "github.com/urfave/cli/v2" +) + +var ( + oauthCLIFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "Application Name", + }, + &cli.StringFlag{ + Name: "provider", + Value: "", + Usage: "OAuth2 Provider", + }, + &cli.StringFlag{ + Name: "key", + Value: "", + Usage: "Client ID (Key)", + }, + &cli.StringFlag{ + Name: "secret", + Value: "", + Usage: "Client Secret", + }, + &cli.StringFlag{ + Name: "auto-discover-url", + Value: "", + Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)", + }, + &cli.StringFlag{ + Name: "use-custom-urls", + Value: "false", + Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints", + }, + &cli.StringFlag{ + Name: "custom-tenant-id", + Value: "", + Usage: "Use custom Tenant ID for OAuth endpoints", + }, + &cli.StringFlag{ + Name: "custom-auth-url", + Value: "", + Usage: "Use a custom Authorization URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-token-url", + Value: "", + Usage: "Use a custom Token URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-profile-url", + Value: "", + Usage: "Use a custom Profile URL (option for GitLab/GitHub)", + }, + &cli.StringFlag{ + Name: "custom-email-url", + Value: "", + Usage: "Use a custom Email URL (option for GitHub)", + }, + &cli.StringFlag{ + Name: "icon-url", + Value: "", + Usage: "Custom icon URL for OAuth2 login source", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Set to true to skip local 2fa for users authenticated by this source", + }, + &cli.StringSliceFlag{ + Name: "scopes", + Value: nil, + Usage: "Scopes to request when to authenticate against this OAuth2 source", + }, + &cli.StringFlag{ + Name: "required-claim-name", + Value: "", + Usage: "Claim name that has to be set to allow users to login with this source", + }, + &cli.StringFlag{ + Name: "required-claim-value", + Value: "", + Usage: "Claim value that has to be set to allow users to login with this source", + }, + &cli.StringFlag{ + Name: "group-claim-name", + Value: "", + Usage: "Claim name providing group names for this source", + }, + &cli.StringFlag{ + Name: "admin-group", + Value: "", + Usage: "Group Claim value for administrator users", + }, + &cli.StringFlag{ + Name: "restricted-group", + Value: "", + Usage: "Group Claim value for restricted users", + }, + &cli.StringFlag{ + Name: "group-team-map", + Value: "", + Usage: "JSON mapping between groups and org teams", + }, + &cli.BoolFlag{ + Name: "group-team-map-removal", + Usage: "Activate automatic team membership removal depending on groups", + }, + } + + microcmdAuthAddOauth = &cli.Command{ + Name: "add-oauth", + Usage: "Add new Oauth authentication source", + Action: runAddOauth, + Flags: oauthCLIFlags, + } + + microcmdAuthUpdateOauth = &cli.Command{ + Name: "update-oauth", + Usage: "Update existing Oauth authentication source", + Action: runUpdateOauth, + Flags: append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...), + } +) + +func parseOAuth2Config(c *cli.Context) *oauth2.Source { + var customURLMapping *oauth2.CustomURLMapping + if c.IsSet("use-custom-urls") { + customURLMapping = &oauth2.CustomURLMapping{ + TokenURL: c.String("custom-token-url"), + AuthURL: c.String("custom-auth-url"), + ProfileURL: c.String("custom-profile-url"), + EmailURL: c.String("custom-email-url"), + Tenant: c.String("custom-tenant-id"), + } + } else { + customURLMapping = nil + } + return &oauth2.Source{ + Provider: c.String("provider"), + ClientID: c.String("key"), + ClientSecret: c.String("secret"), + OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"), + CustomURLMapping: customURLMapping, + IconURL: c.String("icon-url"), + SkipLocalTwoFA: c.Bool("skip-local-2fa"), + Scopes: c.StringSlice("scopes"), + RequiredClaimName: c.String("required-claim-name"), + RequiredClaimValue: c.String("required-claim-value"), + GroupClaimName: c.String("group-claim-name"), + AdminGroup: c.String("admin-group"), + RestrictedGroup: c.String("restricted-group"), + GroupTeamMap: c.String("group-team-map"), + GroupTeamMapRemoval: c.Bool("group-team-map-removal"), + } +} + +func runAddOauth(c *cli.Context) error { + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + config := parseOAuth2Config(c) + if config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL) + } + } + + return auth_model.CreateSource(ctx, &auth_model.Source{ + Type: auth_model.OAuth2, + Name: c.String("name"), + IsActive: true, + Cfg: config, + }) +} + +func runUpdateOauth(c *cli.Context) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + ctx, cancel := installSignals() + defer cancel() + + if err := initDB(ctx); err != nil { + return err + } + + source, err := auth_model.GetSourceByID(ctx, c.Int64("id")) + if err != nil { + return err + } + + oAuth2Config := source.Cfg.(*oauth2.Source) + + if c.IsSet("name") { + source.Name = c.String("name") + } + + if c.IsSet("provider") { + oAuth2Config.Provider = c.String("provider") + } + + if c.IsSet("key") { + oAuth2Config.ClientID = c.String("key") + } + + if c.IsSet("secret") { + oAuth2Config.ClientSecret = c.String("secret") + } + + if c.IsSet("auto-discover-url") { + oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url") + } + + if c.IsSet("icon-url") { + oAuth2Config.IconURL = c.String("icon-url") + } + + if c.IsSet("scopes") { + oAuth2Config.Scopes = c.StringSlice("scopes") + } + + if c.IsSet("required-claim-name") { + oAuth2Config.RequiredClaimName = c.String("required-claim-name") + } + if c.IsSet("required-claim-value") { + oAuth2Config.RequiredClaimValue = c.String("required-claim-value") + } + + if c.IsSet("group-claim-name") { + oAuth2Config.GroupClaimName = c.String("group-claim-name") + } + if c.IsSet("admin-group") { + oAuth2Config.AdminGroup = c.String("admin-group") + } + if c.IsSet("restricted-group") { + oAuth2Config.RestrictedGroup = c.String("restricted-group") + } + if c.IsSet("group-team-map") { + oAuth2Config.GroupTeamMap = c.String("group-team-map") + } + if c.IsSet("group-team-map-removal") { + oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal") + } + + // update custom URL mapping + customURLMapping := &oauth2.CustomURLMapping{} + + if oAuth2Config.CustomURLMapping != nil { + customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL + customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL + customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL + customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL + customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant + } + if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") { + customURLMapping.TokenURL = c.String("custom-token-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") { + customURLMapping.AuthURL = c.String("custom-auth-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") { + customURLMapping.ProfileURL = c.String("custom-profile-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") { + customURLMapping.EmailURL = c.String("custom-email-url") + } + + if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") { + customURLMapping.Tenant = c.String("custom-tenant-id") + } + + oAuth2Config.CustomURLMapping = customURLMapping + source.Cfg = oAuth2Config + + return auth_model.UpdateSource(ctx, source) +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/routers/web/auth/oauth.go b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/routers/web/auth/oauth.go new file mode 100644 index 0000000..aa599bd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/routers/web/auth/oauth.go @@ -0,0 +1,1481 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + go_context "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "html" + "html/template" + "io" + "net/http" + "net/url" + "sort" + "strings" + + asymkey_model "forgejo.org/models/asymkey" + "forgejo.org/models/auth" + org_model "forgejo.org/models/organization" + user_model "forgejo.org/models/user" + auth_module "forgejo.org/modules/auth" + "forgejo.org/modules/base" + "forgejo.org/modules/container" + "forgejo.org/modules/json" + "forgejo.org/modules/log" + "forgejo.org/modules/optional" + "forgejo.org/modules/setting" + "forgejo.org/modules/timeutil" + "forgejo.org/modules/util" + "forgejo.org/modules/web" + "forgejo.org/modules/web/middleware" + auth_service "forgejo.org/services/auth" + source_service "forgejo.org/services/auth/source" + "forgejo.org/services/auth/source/oauth2" + "forgejo.org/services/context" + "forgejo.org/services/externalaccount" + "forgejo.org/services/forms" + remote_service "forgejo.org/services/remote" + user_service "forgejo.org/services/user" + + "code.forgejo.org/go-chi/binding" + "github.com/golang-jwt/jwt/v5" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/fitbit" + "github.com/markbates/goth/providers/openidConnect" + "github.com/markbates/goth/providers/zoom" + go_oauth2 "golang.org/x/oauth2" +) + +const ( + tplGrantAccess base.TplName = "user/auth/grant" + tplGrantError base.TplName = "user/auth/grant_error" +) + +// TODO move error and responses to SDK or models + +// AuthorizeErrorCode represents an error code specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeErrorCode string + +const ( + // ErrorCodeInvalidRequest represents the according error in RFC 6749 + ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request" + // ErrorCodeUnauthorizedClient represents the according error in RFC 6749 + ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client" + // ErrorCodeAccessDenied represents the according error in RFC 6749 + ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied" + // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749 + ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type" + // ErrorCodeInvalidScope represents the according error in RFC 6749 + ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope" + // ErrorCodeServerError represents the according error in RFC 6749 + ErrorCodeServerError AuthorizeErrorCode = "server_error" + // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749 + ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable" +) + +// AuthorizeError represents an error type specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1 +type AuthorizeError struct { + ErrorCode AuthorizeErrorCode `json:"error" form:"error"` + ErrorDescription string + State string +} + +// Error returns the error message +func (err AuthorizeError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// AccessTokenErrorCode represents an error code specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +type AccessTokenErrorCode string + +const ( + // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request" + // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidClient = "invalid_client" + // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidGrant = "invalid_grant" + // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client" + // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749 + AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type" + // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749 + AccessTokenErrorCodeInvalidScope = "invalid_scope" +) + +// AccessTokenError represents an error response specified in RFC 6749 +// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 +type AccessTokenError struct { + ErrorCode AccessTokenErrorCode `json:"error" form:"error"` + ErrorDescription string `json:"error_description"` +} + +// Error returns the error message +func (err AccessTokenError) Error() string { + return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription) +} + +// errCallback represents a oauth2 callback error +type errCallback struct { + Code string + Description string +} + +func (err errCallback) Error() string { + return err.Description +} + +// TokenType specifies the kind of token +type TokenType string + +const ( + // TokenTypeBearer represents a token type specified in RFC 6749 + TokenTypeBearer TokenType = "bearer" + // TokenTypeMAC represents a token type specified in RFC 6749 + TokenTypeMAC = "mac" +) + +// AccessTokenResponse represents a successful access token response +// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2 +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType TokenType `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token,omitempty"` +} + +func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) { + if setting.OAuth2.InvalidateRefreshTokens { + if err := grant.IncreaseCounter(ctx); err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "cannot increase the grant counter", + } + } + } + // generate access token to access the API + expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime) + accessToken := &oauth2.Token{ + GrantID: grant.ID, + Type: oauth2.TypeAccessToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + }, + } + signedAccessToken, err := accessToken.SignToken(serverKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate refresh token to request an access token after it expired later + refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime() + refreshToken := &oauth2.Token{ + GrantID: grant.ID, + Counter: grant.Counter, + Type: oauth2.TypeRefreshToken, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExpirationDate), + }, + } + signedRefreshToken, err := refreshToken.SignToken(serverKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + + // generate OpenID Connect id_token + signedIDToken := "" + if grant.ScopeContains("openid") { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find application", + } + } + user, err := user_model.GetUserByID(ctx, grant.UserID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot find user", + } + } + log.Error("Error loading user: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + + idToken := &oauth2.OIDCToken{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()), + Issuer: setting.AppURL, + Audience: []string{app.ClientID}, + Subject: fmt.Sprint(grant.UserID), + }, + Nonce: grant.Nonce, + } + if grant.ScopeContains("profile") { + idToken.Name = user.DisplayName() + idToken.PreferredUsername = user.Name + idToken.Profile = user.HTMLURL() + idToken.Picture = user.AvatarLink(ctx) + idToken.Website = user.Website + idToken.Locale = user.Language + idToken.UpdatedAt = user.UpdatedUnix + } + if grant.ScopeContains("email") { + idToken.Email = user.Email + idToken.EmailVerified = user.IsActive + } + if grant.ScopeContains("groups") { + onlyPublicGroups := ifOnlyPublicGroups(grant.Scope) + + groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups) + if err != nil { + log.Error("Error getting groups: %v", err) + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "server error", + } + } + idToken.Groups = groups + } + + signedIDToken, err = idToken.SignToken(clientKey) + if err != nil { + return nil, &AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot sign token", + } + } + } + + return &AccessTokenResponse{ + AccessToken: signedAccessToken, + TokenType: TokenTypeBearer, + ExpiresIn: setting.OAuth2.AccessTokenExpirationTime, + RefreshToken: signedRefreshToken, + IDToken: signedIDToken, + }, nil +} + +type userInfoResponse struct { + Sub string `json:"sub"` + Name string `json:"name"` + Username string `json:"preferred_username"` + Email string `json:"email"` + Picture string `json:"picture"` + Groups []string `json:"groups,omitempty"` +} + +func ifOnlyPublicGroups(scopes string) bool { + scopes = strings.ReplaceAll(scopes, ",", " ") + scopesList := strings.Fields(scopes) + for _, scope := range scopesList { + if scope == "all" || scope == "read:organization" || scope == "read:admin" { + return false + } + } + return true +} + +// InfoOAuth manages request for userinfo endpoint +func InfoOAuth(ctx *context.Context) { + if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() { + ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + response := &userInfoResponse{ + Sub: fmt.Sprint(ctx.Doer.ID), + Name: ctx.Doer.DisplayName(), + Username: ctx.Doer.Name, + Email: ctx.Doer.Email, + Picture: ctx.Doer.AvatarLink(ctx), + } + + var token string + if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" { + auths := strings.Fields(auHead) + if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") { + token = auths[1] + } + } + + _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token) + onlyPublicGroups := ifOnlyPublicGroups(grantScopes) + + groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups) + if err != nil { + ctx.ServerError("Oauth groups for user", err) + return + } + response.Groups = groups + + ctx.JSON(http.StatusOK, response) +} + +// returns a list of "org" and "org:team" strings, +// that the given user is a part of. +func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) { + orgs, err := org_model.GetUserOrgsList(ctx, user) + if err != nil { + return nil, fmt.Errorf("GetUserOrgList: %w", err) + } + + var groups []string + for _, org := range orgs { + if setting.OAuth2.EnableAdditionalGrantScopes { + if onlyPublicGroups { + public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID) + if !public && err == nil { + continue + } + } + } + + groups = append(groups, org.Name) + teams, err := org.LoadTeams(ctx) + if err != nil { + return nil, fmt.Errorf("LoadTeams: %w", err) + } + for _, team := range teams { + if team.IsMember(ctx, user.ID) { + groups = append(groups, org.Name+":"+team.LowerName) + } + } + } + return groups, nil +} + +func parseBasicAuth(ctx *context.Context) (username, password string, err error) { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + return base.BasicAuthDecode(authData) + } + return "", "", errors.New("invalid basic authentication") +} + +// IntrospectOAuth introspects an oauth token +func IntrospectOAuth(ctx *context.Context) { + clientIDValid := false + if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID) + if err != nil && !auth.IsErrOauthClientIDInvalid(err) { + // this is likely a database error; log it and respond without details + log.Error("Error retrieving client_id: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret)) + } + if !clientIDValid { + ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`) + ctx.PlainText(http.StatusUnauthorized, "no valid authorization") + return + } + + var response struct { + Active bool `json:"active"` + Scope string `json:"scope,omitempty"` + Username string `json:"username,omitempty"` + jwt.RegisteredClaims + } + + form := web.GetForm(ctx).(*forms.IntrospectTokenForm) + token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey) + if err == nil { + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err == nil && grant != nil { + app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID) + if err == nil && app != nil { + response.Active = true + response.Scope = grant.Scope + response.Issuer = setting.AppURL + response.Audience = []string{app.ClientID} + response.Subject = fmt.Sprint(grant.UserID) + } + if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil { + response.Username = user.Name + } + } + } + + ctx.JSON(http.StatusOK, response) +} + +// AuthorizeOAuth manages authorize requests +func AuthorizeOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AuthorizationForm) + errs := binding.Errors{} + errs = form.Validate(ctx.Req, errs) + if len(errs) > 0 { + errstring := "" + for _, e := range errs { + errstring += e.Error() + "\n" + } + ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring)) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + if auth.IsErrOauthClientIDInvalid(err) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnauthorizedClient, + ErrorDescription: "Client ID not registered", + State: form.State, + }, "") + return + } + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + + var user *user_model.User + if app.UID != 0 { + user, err = user_model.GetUserByID(ctx, app.UID) + if err != nil { + ctx.ServerError("GetUserByID", err) + return + } + } + + if !app.ContainsRedirectURI(form.RedirectURI) { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "Unregistered Redirect URI", + State: form.State, + }, "") + return + } + + if form.ResponseType != "code" { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeUnsupportedResponseType, + ErrorDescription: "Only code response type is supported.", + State: form.State, + }, form.RedirectURI) + return + } + + // pkce support + switch form.CodeChallengeMethod { + case "S256": + case "plain": + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "cannot set code challenge", + State: form.State, + }, form.RedirectURI) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + case "": + // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message" + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1 + if !app.ConfidentialClient { + // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request"" + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "PKCE is required for public clients", + State: form.State, + }, form.RedirectURI) + return + } + default: + // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"." + // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1 + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeInvalidRequest, + ErrorDescription: "unsupported code challenge method", + State: form.State, + }, form.RedirectURI) + return + } + + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + + // Redirect if user already granted access and the application is confidential. + // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2 + if app.ConfidentialClient && grant != nil { + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + // Update nonce to reflect the new session + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + ctx.Redirect(redirect.String()) + return + } + + // show authorize page to grant access + ctx.Data["Application"] = app + ctx.Data["RedirectURI"] = form.RedirectURI + ctx.Data["State"] = form.State + ctx.Data["Scope"] = form.Scope + ctx.Data["Nonce"] = form.Nonce + if user != nil { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name))) + } else { + ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName))) + } + ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "") + // TODO document SESSION <=> FORM + err = ctx.Session.Set("client_id", app.ClientID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("redirect_uri", form.RedirectURI) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + log.Error(err.Error()) + return + } + err = ctx.Session.Set("state", form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + log.Error(err.Error()) + return + } + // Here we're just going to try to release the session early + if err := ctx.Session.Release(); err != nil { + // we'll tolerate errors here as they *should* get saved elsewhere + log.Error("Unable to save changes to the session: %v", err) + } + ctx.HTML(http.StatusOK, tplGrantAccess) +} + +// GrantApplicationOAuth manages the post request submitted when a user grants access to an application +func GrantApplicationOAuth(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.GrantApplicationForm) + if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State || + ctx.Session.Get("redirect_uri") != form.RedirectURI { + ctx.Error(http.StatusBadRequest) + return + } + + if !form.Granted { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "the request is denied", + ErrorCode: ErrorCodeAccessDenied, + }, form.RedirectURI) + return + } + + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + ctx.ServerError("GetOAuth2ApplicationByClientID", err) + return + } + grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + if grant == nil { + grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope) + if err != nil { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "cannot create grant for user", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + } else if grant.Scope != form.Scope { + handleAuthorizeError(ctx, AuthorizeError{ + State: form.State, + ErrorDescription: "a grant exists with different scope", + ErrorCode: ErrorCodeServerError, + }, form.RedirectURI) + return + } + + if len(form.Nonce) > 0 { + err := grant.SetNonce(ctx, form.Nonce) + if err != nil { + log.Error("Unable to update nonce: %v", err) + } + } + + var codeChallenge, codeChallengeMethod string + codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string) + codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string) + + code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + redirect, err := code.GenerateRedirectURI(form.State) + if err != nil { + handleServerError(ctx, form.State, form.RedirectURI, err) + return + } + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities +func OIDCWellKnown(ctx *context.Context) { + ctx.Data["SigningKey"] = oauth2.DefaultSigningKey + ctx.JSONTemplate("user/auth/oidc_wellknown") +} + +// OIDCKeys generates the JSON Web Key Set +func OIDCKeys(ctx *context.Context) { + jwk, err := oauth2.DefaultSigningKey.ToJWK() + if err != nil { + log.Error("Error converting signing key to JWK: %v", err) + ctx.Error(http.StatusInternalServerError) + return + } + + jwk["use"] = "sig" + + jwks := map[string][]map[string]string{ + "keys": { + jwk, + }, + } + + ctx.Resp.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(ctx.Resp) + if err := enc.Encode(jwks); err != nil { + log.Error("Failed to encode representation as json. Error: %v", err) + } +} + +// AccessTokenOAuth manages all access token requests by the client +func AccessTokenOAuth(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.AccessTokenForm) + // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header + if form.ClientID == "" || form.ClientSecret == "" { + authHeader := ctx.Req.Header.Get("Authorization") + if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") { + clientID, clientSecret, err := base.BasicAuthDecode(authData) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot parse basic auth header", + }) + return + } + // validate that any fields present in the form match the Basic auth header + if form.ClientID != "" && form.ClientID != clientID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_id in request body inconsistent with Authorization header", + }) + return + } + form.ClientID = clientID + if form.ClientSecret != "" && form.ClientSecret != clientSecret { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "client_secret in request body inconsistent with Authorization header", + }) + return + } + form.ClientSecret = clientSecret + } + } + + serverKey := oauth2.DefaultSigningKey + clientKey := serverKey + if serverKey.IsSymmetric() { + var err error + clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret)) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "Error creating signing key", + }) + return + } + } + + switch form.GrantType { + case "refresh_token": + handleRefreshToken(ctx, form, serverKey, clientKey) + case "authorization_code": + handleAuthorizationCode(ctx, form, serverKey, clientKey) + default: + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnsupportedGrantType, + ErrorDescription: "Only refresh_token or authorization_code grant type is supported", + }) + } +} + +func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID), + }) + return + } + // "The authorization server MUST ... require client authentication for confidential clients" + // https://datatracker.ietf.org/doc/html/rfc6749#section-6 + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + // "invalid_client ... Client authentication failed" + // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: errorDescription, + }) + return + } + + token, err := oauth2.ParseToken(form.RefreshToken, serverKey) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unable to parse refresh token", + }) + return + } + // get grant before increasing counter + grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID) + if err != nil || grant == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "grant does not exist", + }) + return + } + + // check if token got already used + if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "token was already used", + }) + log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID) + return + } + accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + ctx.JSON(http.StatusOK, accessToken) +} + +func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) { + app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID) + if err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidClient, + ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID), + }) + return + } + if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) { + errorDescription := "invalid client secret" + if form.ClientSecret == "" { + errorDescription = "invalid empty client secret" + } + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: errorDescription, + }) + return + } + if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "unexpected redirect URI", + }) + return + } + authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code) + if err != nil || authorizationCode == nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "client is not authorized", + }) + return + } + // check if code verifier authorizes the client, PKCE support + if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeUnauthorizedClient, + ErrorDescription: "failed PKCE code challenge", + }) + return + } + // check if granted for this application + if authorizationCode.Grant.ApplicationID != app.ID { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidGrant, + ErrorDescription: "invalid grant", + }) + return + } + // remove token from database to deny duplicate usage + if err := authorizationCode.Invalidate(ctx); err != nil { + handleAccessTokenError(ctx, AccessTokenError{ + ErrorCode: AccessTokenErrorCodeInvalidRequest, + ErrorDescription: "cannot proceed your request", + }) + } + resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey) + if tokenErr != nil { + handleAccessTokenError(ctx, *tokenErr) + return + } + // send successful response + ctx.JSON(http.StatusOK, resp) +} + +func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) { + ctx.JSON(http.StatusBadRequest, acErr) +} + +func handleServerError(ctx *context.Context, state, redirectURI string, err error) { + log.Error("OAuth server error: %v", err) + handleAuthorizeError(ctx, AuthorizeError{ + ErrorCode: ErrorCodeServerError, + ErrorDescription: "A server error occurred", + State: state, + }, redirectURI) +} + +func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) { + if redirectURI == "" { + log.Warn("Authorization failed: %v", authErr.ErrorDescription) + ctx.Data["Error"] = authErr + ctx.HTML(http.StatusBadRequest, tplGrantError) + return + } + redirect, err := url.Parse(redirectURI) + if err != nil { + ctx.ServerError("url.Parse", err) + return + } + q := redirect.Query() + q.Set("error", string(authErr.ErrorCode)) + q.Set("error_description", authErr.ErrorDescription) + q.Set("state", authErr.State) + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String(), http.StatusSeeOther) +} + +// SignInOAuth handles the OAuth2 login buttons +func SignInOAuth(ctx *context.Context) { + provider := ctx.Params(":provider") + + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + redirectTo := ctx.FormString("redirect_to") + if len(redirectTo) > 0 { + middleware.SetRedirectToCookie(ctx.Resp, redirectTo) + } + + // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user + user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err == nil && user != nil { + // we got the user without going through the whole OAuth2 authentication flow again + handleOAuth2SignIn(ctx, authSource, user, gothUser) + return + } + + codeChallenge, err := generateCodeChallenge(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err)) + return + } + + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + if strings.Contains(err.Error(), "no provider for ") { + if err = oauth2.ResetOAuth2(ctx); err != nil { + ctx.ServerError("SignIn", err) + return + } + if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil { + ctx.ServerError("SignIn", err) + } + return + } + ctx.ServerError("SignIn", err) + } + // redirect is done in oauth2.Auth +} + +// SignInOAuthCallback handles the callback from the given provider +func SignInOAuthCallback(ctx *context.Context) { + provider := ctx.Params(":provider") + + if ctx.Req.FormValue("error") != "" { + var errorKeyValues []string + for k, vv := range ctx.Req.Form { + for _, v := range vv { + errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v))) + } + } + sort.Strings(errorKeyValues) + ctx.Flash.Error(strings.Join(errorKeyValues, "
"), true) + } + + // first look if the provider is still active + authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider) + if err != nil { + ctx.ServerError("SignIn", err) + return + } + + if authSource == nil { + ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider")) + return + } + + u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp) + if err != nil { + if user_model.IsErrUserProhibitLogin(err) { + uplerr := err.(user_model.ErrUserProhibitLogin) + log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err) + ctx.Data["Title"] = ctx.Tr("auth.prohibit_login") + ctx.HTML(http.StatusOK, "user/auth/prohibit_login") + return + } + if callbackErr, ok := err.(errCallback); ok { + log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description) + switch callbackErr.Code { + case "access_denied": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied")) + case "temporarily_unavailable": + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable")) + default: + ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error")) + } + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + if err, ok := err.(*go_oauth2.RetrieveError); ok { + ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true) + ctx.Redirect(setting.AppSubURL + "/user/login") + return + } + ctx.ServerError("UserSignIn", err) + return + } + + if u == nil { + if ctx.Doer != nil { + // attach user to already logged in user + err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser) + if err != nil { + ctx.ServerError("UserLinkAccount", err) + return + } + + ctx.Redirect(setting.AppSubURL + "/user/settings/security") + return + } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration { + // create new user with details from oauth2 provider + if gothUser.UserID == "" { + log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) + if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" { + log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields") + } + err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name) + ctx.ServerError("CreateUser", err) + return + } + var missingFields []string + if gothUser.Email == "" { + missingFields = append(missingFields, "email") + } + if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" { + missingFields = append(missingFields, "nickname") + } + if len(missingFields) > 0 { + // we don't have enough information to create an account automatically, + // so we prompt the user for the remaining bits + log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields) + showLinkingLogin(ctx, gothUser) + return + } + uname, err := getUserName(&gothUser) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + u = &user_model.User{ + Name: uname, + FullName: gothUser.Name, + Email: gothUser.Email, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + LoginName: gothUser.UserID, + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm), + } + + source := authSource.Cfg.(*oauth2.Source) + + isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser) + u.IsAdmin = isAdmin.ValueOrDefault(false) + u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted) + + if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) { + // error already handled + return + } + + if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } else { + // no existing user is found, request attach or new account + showLinkingLogin(ctx, gothUser) + return + } + } + + handleOAuth2SignIn(ctx, authSource, u, gothUser) +} + +func claimValueToStringSet(claimValue any) container.Set[string] { + var groups []string + + switch rawGroup := claimValue.(type) { + case []string: + groups = rawGroup + case []any: + for _, group := range rawGroup { + groups = append(groups, fmt.Sprintf("%s", group)) + } + default: + str := fmt.Sprintf("%s", rawGroup) + groups = strings.Split(str, ",") + } + return container.SetOf(groups...) +} + +func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error { + if source.GroupTeamMap != "" || source.GroupTeamMapRemoval { + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap) + if err != nil { + return err + } + + groups := getClaimedGroups(source, gothUser) + + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil { + return err + } + } + + return nil +} + +func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] { + groupClaims, has := gothUser.RawData[source.GroupClaimName] + if !has { + return nil + } + + return claimValueToStringSet(groupClaims) +} + +func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) { + groups := getClaimedGroups(source, gothUser) + + if source.AdminGroup != "" { + isAdmin = optional.Some(groups.Contains(source.AdminGroup)) + } + if source.RestrictedGroup != "" { + isRestricted = optional.Some(groups.Contains(source.RestrictedGroup)) + } + + return isAdmin, isRestricted +} + +func showLinkingLogin(ctx *context.Context, gothUser goth.User) { + if err := updateSession(ctx, nil, map[string]any{ + "linkAccountGothUser": gothUser, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + ctx.Redirect(setting.AppSubURL + "/user/link_account") +} + +func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { + if setting.OAuth2Client.UpdateAvatar && len(url) > 0 { + resp, err := http.Get(url) + if err == nil { + defer func() { + _ = resp.Body.Close() + }() + } + // ignore any error + if err == nil && resp.StatusCode == http.StatusOK { + data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1)) + if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize { + _ = user_service.UploadAvatar(ctx, u, data) + } + } + } +} + +func getSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) { + key := source.AttributeSSHPublicKey + value, exists := gothUser.RawData[key] + if !exists { + return []string{}, nil + } + + rawSlice, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("unexpected type for SSH public key, expected []interface{} but got %T", value) + } + + sshKeys := make([]string, 0, len(rawSlice)) + for i, v := range rawSlice { + str, ok := v.(string) + if !ok { + return nil, fmt.Errorf("unexpected element type at index %d in SSH public key array, expected string but got %T", i, v) + } + sshKeys = append(sshKeys, str) + } + + return sshKeys, nil +} + +func updateSSHPubIfNeed( + ctx *context.Context, + authSource *auth.Source, + fetchedUser *goth.User, + user *user_model.User, +) error { + oauth2Source := authSource.Cfg.(*oauth2.Source) + + if oauth2Source.ProvidesSSHKeys() { + sshKeys, err := getSSHKeys(oauth2Source, fetchedUser) + if err != nil { + return err + } + + if asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) { + err = asymkey_model.RewriteAllPublicKeys(ctx) + if err != nil { + return err + } + } + } + + return nil +} + +func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { + updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + err := updateSSHPubIfNeed(ctx, source, &gothUser, u) + if err != nil { + ctx.ServerError("updateSSHPubIfNeed", err) + return + } + + needs2FA := false + if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { + needs2FA, err = auth.HasTwoFactorByUID(ctx, u.ID) + if err != nil { + ctx.ServerError("UserSignIn", err) + return + } + } + + oauth2Source := source.Cfg.(*oauth2.Source) + groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap) + if err != nil { + ctx.ServerError("UnmarshalGroupTeamMapping", err) + return + } + + groups := getClaimedGroups(oauth2Source, &gothUser) + + // If this user is enrolled in 2FA and this source doesn't override it, + // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page. + if !needs2FA { + if err := updateSession(ctx, nil, map[string]any{ + "uid": u.ID, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // Clear whatever CSRF cookie has right now, force to generate a new one + ctx.Csrf.DeleteCookie(ctx) + + opts := &user_service.UpdateOptions{ + SetLastLogin: true, + } + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + // update external user information + if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil { + if !errors.Is(err, util.ErrNotExist) { + log.Error("UpdateExternalUser failed: %v", err) + } + } + + if err := resetLocale(ctx, u); err != nil { + ctx.ServerError("resetLocale", err) + return + } + + if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 { + middleware.DeleteRedirectToCookie(ctx.Resp) + ctx.RedirectToFirst(redirectTo) + return + } + + ctx.Redirect(setting.AppSubURL + "/") + return + } + + opts := &user_service.UpdateOptions{} + opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser) + if opts.IsAdmin.Has() || opts.IsRestricted.Has() { + if err := user_service.UpdateUser(ctx, u, opts); err != nil { + ctx.ServerError("UpdateUser", err) + return + } + } + + if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval { + if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil { + ctx.ServerError("SyncGroupsToTeams", err) + return + } + } + + if err := updateSession(ctx, nil, map[string]any{ + // User needs to use 2FA, save data and redirect to 2FA page. + "twofaUid": u.ID, + "twofaRemember": false, + }); err != nil { + ctx.ServerError("updateSession", err) + return + } + + // If WebAuthn is enrolled -> Redirect to WebAuthn instead + regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID) + if err == nil && len(regs) > 0 { + ctx.Redirect(setting.AppSubURL + "/user/webauthn") + return + } + + ctx.Redirect(setting.AppSubURL + "/user/two_factor") +} + +// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE +func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) { + // the `code_verifier` is only forwarded by specific providers + // https://codeberg.org/forgejo/forgejo/issues/4033 + p, ok := goth.GetProviders()[provider] + if !ok { + return "", nil + } + switch p.(type) { + default: + return "", nil + case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider: + // those providers forward the `code_verifier` + // a code_challenge can be generated + } + + codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness) + if err != nil { + return "", err + } + if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil { + return "", err + } + return encodeCodeChallenge(codeVerifier) +} + +func encodeCodeChallenge(codeVerifier string) (string, error) { + hasher := sha256.New() + _, err := io.WriteString(hasher, codeVerifier) + codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) + return codeChallenge, err +} + +// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful +// login the user +func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil { + return nil, goth.User{}, err + } + + u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser) + return u, gothUser, err +} + +func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { + oauth2Source := authSource.Cfg.(*oauth2.Source) + + // Make sure that the response is not an error response. + errorName := request.FormValue("error") + + if len(errorName) > 0 { + errorDescription := request.FormValue("error_description") + + // Delete the goth session + err := gothic.Logout(response, request) + if err != nil { + return goth.User{}, err + } + + return goth.User{}, errCallback{ + Code: errorName, + Description: errorDescription, + } + } + + // Proceed to authenticate through goth. + codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string) + _ = ctx.Session.Delete("CodeVerifier") + gothUser, err := oauth2Source.Callback(request, response, codeVerifier) + if err != nil { + if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") { + log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) + err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) + } + return goth.User{}, err + } + + if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + + if oauth2Source.RequiredClaimValue != "" { + groups := claimValueToStringSet(claimInterface) + + if !groups.Contains(oauth2Source.RequiredClaimValue) { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + } + } + } + + return gothUser, nil +} + +func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { + user := &user_model.User{ + LoginName: gothUser.UserID, + LoginType: auth.OAuth2, + LoginSource: authSource.ID, + } + + hasUser, err := user_model.GetUser(ctx, user) + if err != nil { + return nil, err + } + + if hasUser { + return user, nil + } + log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) + + // search in external linked users + externalLoginUser := &user_model.ExternalLoginUser{ + ExternalID: gothUser.UserID, + LoginSourceID: authSource.ID, + } + hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser) + if err != nil { + return nil, err + } + if hasUser { + user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) + return user, err + } + + // no user found to login + return nil, nil +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/services/auth/source/oauth2/source.go b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/services/auth/source/oauth2/source.go new file mode 100644 index 0000000..5245f88 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/forgejo-v11.0.3/services/auth/source/oauth2/source.go @@ -0,0 +1,59 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "strings" + + "forgejo.org/models/auth" + "forgejo.org/modules/json" +) + +// Source holds configuration for the OAuth2 login source. +type Source struct { + Provider string + ClientID string + ClientSecret string + OpenIDConnectAutoDiscoveryURL string + CustomURLMapping *CustomURLMapping + IconURL string + + Scopes []string + AttributeSSHPublicKey string + RequiredClaimName string + RequiredClaimValue string + GroupClaimName string + AdminGroup string + GroupTeamMap string + GroupTeamMapRemoval bool + RestrictedGroup string + SkipLocalTwoFA bool `json:",omitempty"` + + // reference to the authSource + authSource *auth.Source +} + +// FromDB fills up an OAuth2Config from serialized format. +func (source *Source) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &source) +} + +// ToDB exports an SMTPConfig to a serialized format. +func (source *Source) ToDB() ([]byte, error) { + return json.Marshal(source) +} + +// ProvidesSSHKeys returns if this source provides SSH Keys +func (source *Source) ProvidesSSHKeys() bool { + return len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 +} + +// SetAuthSource sets the related AuthSource +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.OAuth2, &Source{}) +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/validation.json b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/validation.json new file mode 100644 index 0000000..59ae3e3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/validation.json @@ -0,0 +1,59 @@ +{ + "scope": "offline-source-and-public-chart-only", + "canonical_edits": false, + "owner_acknowledgment": false, + "case_source_accepted": false, + "full_first_case_configuration": "refused: unsupported exact any-of gate", + "tests": { + "gate_evidence": { + "joined_or_regex_value_matches_any_individual_case_group": false, + "running_forgejo_callback_test": false, + "singular_literal_operand": true + }, + "reference_render": { + "actual_case_gate": "refused", + "ambient_environment": false, + "chart": "12.5.0", + "cli_flags_match_pinned_forgejo": true, + "forgejo_image": "11.0.3", + "native_calls": false, + "private_helper_executed": false, + "secret_reference": { + "key": "CLIENT_SECRET", + "name": "forgejo-netkingdom-oidc-client" + }, + "secret_values_used": false + }, + "rendered_negative_controls": { + "actual_delivery_keys": [ + "CLIENT_SECRET" + ], + "existingSecret_requests": [ + "key", + "secret" + ], + "helm_schema_does_not_reject_unknown_oauth_keys": true, + "invented_list_field_renders_unknown_cli_flag": "--required-claim-values" + }, + "successful": true, + "tests_run": 9 + }, + "validation_history": [ + { + "outcome": "failed", + "tests_run": 7, + "reason": "review fixture selected Deployment named forgejo, chart actually uses forgejo-gitea", + "correction": "select actual Deployment by app.kubernetes.io/instance=forgejo; no chart/policy weakening" + }, + { + "outcome": "passed", + "tests_run": 7 + }, + { + "outcome": "passed", + "tests_run": 9, + "environment": "empty HOME, allowlisted PATH/PYTHONDONTWRITEBYTECODE; Helm child receives own isolated paths" + } + ], + "isolated_patch_apply_and_exact_postimages": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/platform-delivery-review-r1/README.md b/docs/changes/netkingdom-target-2026-10-17/platform-delivery-review-r1/README.md new file mode 100644 index 0000000..003a4b6 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/platform-delivery-review-r1/README.md @@ -0,0 +1,151 @@ +# Independent Platform delivery mechanics review r1 + +Disposition: **changes required before canonical integration**. This review is +of source preparation, not owner acceptance, native execution, an authority +grant, authenticated delivery evidence, or completion of RPF-1.3-T4. + +Reviewed `platform-delivery-backend-r1` against Platform +`104ed90f42a9a86bf9fda343b49b65a85c9ee4b9` (also actual local HEAD), its +AGENTS/INTENT, fixed template/helper and actual command runner. Original +candidate and canonical repositories are unchanged. No credentials, network, +model calls, production operations, commits or dispatch occurred. + +## Findings + +1. **Stale Ready can become a success receipt.** `_ready` tests only a + Ready=True condition. A matching spec at generation 2 with old + `syncedResourceVersion=1-old-spec` and old `refreshTime` is accepted, as is + Ready=True without either freshness field. The Store gate likewise accepts + an old Ready condition after a prior spec change. Metadata RV records an + observation; it does not establish which spec ESO successfully reconciled. + A later observation with the same UID cannot prove that either. + + The locally cached chart `external-secrets-0.16.1.tgz` declares appVersion + v0.16.1 and is the version pinned by Platform's Argo source declaration. + Its actual v1 ExternalSecret schema has `status.syncedResourceVersion` and + `status.refreshTime`, and no `status.observedGeneration` or condition + observedGeneration. A Deployment-style observedGeneration gate would not + implement this contract. The controller's encoding of syncedResourceVersion + was not available in the inspected local source; no encoding is invented + here. Pin/review actual controller semantics and prove freshness against + the current spec before returning success. Source chart evidence is not a + live installed-version observation. + +2. **Normal API defaulting breaks the supposedly exact match.** The actual v1 + CRD adds `remoteRef.conversionStrategy=Default`, `decodingStrategy=None`, + and `metadataPolicy=None`. The fixed template omits them. Applying the + cached schema's defaults to a synthetic create response makes `_match` + reject it: delivery becomes Uncertain and loses the returned UID as deletion + provenance because assignment occurs only after `_match` succeeds. This is + an ordinary API-shaped response, not an attack or conflicting resource. + The existing fixed helper also has the same exact-spec limitation. + + `partial-defaults-fix.patch` separately fixes this narrowly in the helper + and candidate: absent and explicit values of exactly these defaults compare + equally, while all other fields remain exact. The source template and its + digest remain unchanged. This patch does **not** repair the other findings. + +3. **Store identity/continuity is unproven.** Only one Store GET occurs, before + the ExternalSecret absence check and create. Its UID/RV are never required + or retained. Replacing or changing the Store between that response and + create is undetected; the ExternalSecret references only kind/name. No + subsequent Store observation precedes the success receipt. Rechecking + before create and success would detect some races, but cannot atomically + bind a cross-resource create to the Store UID/RV. An actual existing owner + enforcement mechanism or an explicitly limited guarantee is needed; a + fictional admission policy is not supplied by this review. + +4. **Output capture is unbounded; timeout claims need qualification.** Actual + `state_hub_preflight_lane.command` calls + `subprocess.run(capture_output=True, timeout=60)`. The 262144-byte stdout + limit runs after all stdout and stderr have been collected. Stderr has no + limit at all. A socket denial and mocked run function cannot prove resource + bounds. Require a transport that bounds bytes during collection, includes + stderr, terminates/reaps on overflow and timeout, and returns fixed errors. + Kubectl receives `--request-timeout=5s`, but the enclosing process timeout + is 60 seconds. Ten polls bound command count, not delivery to ten seconds: + the nominal worst-case create route is 13 commands times 60 seconds plus + 9 seconds of sleeps (789 seconds); remove is 12 times 60 plus 9 (729). + These are configured nominal bounds, not a tested global deadline or a + guarantee about descendant process cleanup. + +5. **Read API version depends on discovery.** GET uses the fully qualified + plural/group, which correctly restricts the resource kind, but does not pin + v1 in the request. DELETE explicitly uses + `/apis/external-secrets.io/v1/namespaces/sso/externalsecrets/`. + Matching a returned apiVersion rejects a different response version; it + does not prove the request used the intended API version. Explicitly pin + reads when that is part of the owner contract. The cached chart advertises + v1 served/storage and v1beta1 served; no live discovery was performed. + +6. **Malformed object errors can escape the claimed fixed envelope.** A create + response with `metadata: null` raises AttributeError outside `_call` rather + than Refused/Uncertain. The pre-dispatch pending flag remains set, which + prevents a retry, but the fixed reconciliation/error contract is incomplete. + Validate every nested metadata shape and preserve fixed uncertainty at all + post-mutation boundaries. No input values should enter error strings. + +## Guarantees actually supported by the tests and inspected source + +- Template-derived fixed client name, namespace `sso`, custody path, key names, + exact Store spec and existing Store Ready gate; no caller store/namespace/ + remoteRef overrides. Only ExternalSecret is created. No Kubernetes Secret + read/list/create/delete or Store mutation is dispatched. +- Fetch errors cannot become absence. Create is `create`, not apply/upsert. + Conflicting existing ExternalSecrets are refused, and matching existing + objects yield unchanged receipts without acquiring deletion provenance. +- A verified instance-created object's UID is kept across polling; replacement + is refused. RV is refreshed on successful readiness observation. +- DELETE has **both** UID and resourceVersion API preconditions, in one + DeleteOptions body. An intervening status/spec update or replacement is + therefore expected to conflict at the API rather than delete another + revision. Tests prove generated argv/body and conservative handling of + errors; actual API-server enforcement was not exercised. +- Lost create/delete responses, a replaced UID and unconfirmed deletion keep + pending reconciliation obligations and prevent blind retry in that instance. + A supplied revision or fresh process cannot manufacture deletion provenance. + Successful absence means absence of the fixed ExternalSecret at observation + time; it does not prove target Secret garbage collection or credential revoke. +- Production factory unconditionally refuses before any command. Empty env and + `/dev/null` kubeconfig remain deliberate preparation restrictions. Private + mechanics and caller-supplied Python objects establish no authority. + +This is not one-shot durable admission: a successful unchanged delivery can be +called again, and instance state is lost on restart. The candidate correctly +does not claim that its private state replaces the missing owner journal. + +## Validation and exact partial correction + +`runs.json` and logs retain all final results: + +| Run | Result | Meaning | +| --- | --- | --- | +| Original command regressions | 17/17 pass | Reproduces candidate's suite | +| Existing Platform fixed-contract suite | 8/8 pass | Reproduces base contract checks | +| Independent review tests | 12/12 pass | Includes explicit **defect reproductions**, not acceptance tests | +| Partial defaults fix, original suites plus new adversarial checks | 29/29 pass | Three exact defaults accepted; alternate strategies, extra fields, wrong identity, unready metadata refused | +| Incremental patch check against original r1 scratch tree | pass | `git apply --check` only | + +All tests run with an empty inherited environment, empty scratch HOME, no +credential variables and no live command dispatch. Independent tests and the +17 command tests deny socket construction and intercept subprocess.run. +Contract tests are pure metadata operations. The public chart was rendered +locally from cache by Helm; no chart download or cluster access occurred. +Rendered v1 schemas are retained for reproducible defaults/status evidence. + +The incremental partial patch applies **after** the original candidate patch, +not directly to bare Platform 104ed90. `partial-fix-source/scripts/` contains +its exact resulting helper and delivery script. Stage those in a scratch +Platform-shaped tree with the pinned template/command helper, then run the +original two suites plus `test_partial_defaults_fix.py`. Source and artifact +digests are in `source-pins.json`. No canonical integration is approved by a +passing partial-fix suite. Stale Ready, Store continuity, bounded transport, +exact-version reads and malformed-response handling remain unresolved. + +Production integration additionally still requires actual owner-authenticated +case/client binding, current scoped caller/session, durable one-shot admission +and recovery journal, authenticated evidence publication/verification and +per-attempt revocation. Keep `production_bindings()` refusing until those real +facilities exist and have been independently verified. Neither this review, +the partial patch, source pins, mock receipts, nor clearing transport +restrictions supplies those facilities or makes dispatch operational.