WIP: OIDC Authenticated Pipelines #454

closed
opened by tom.sherman.is targeting master from [deleted fork]: spindle-oidc

This is a WIP, mostly vibe coded PR that should show the direction of the implementation. Hopefully it gets the point across.

I haven't been able to run this yet because struggling to get my local environment working.

Changed files
+980 -214
api
appview
config
db
oauth
handler
pages
templates
signup
spindles
strings
cmd
docs
lexicons
pipeline
nix
spindle
+1
.gitignore
···
.DS_Store
.env
*.rdb
+
.envrc
+2 -3
nix/pkgs/genjwks.nix
···
{
-
gitignoreSource,
+
src,
buildGoApplication,
modules,
}:
buildGoApplication {
pname = "genjwks";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
inherit src modules;
subPackages = ["cmd/genjwks"];
doCheck = false;
CGO_ENABLED = 0;
+2 -3
nix/pkgs/knot-unwrapped.nix
···
buildGoApplication,
modules,
sqlite-lib,
-
gitignoreSource,
+
src,
}:
buildGoApplication {
pname = "knot";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
inherit src modules;
doCheck = false;
+2 -3
nix/pkgs/spindle.nix
···
buildGoApplication,
modules,
sqlite-lib,
-
gitignoreSource,
+
src,
}:
buildGoApplication {
pname = "spindle";
version = "0.1.0";
-
src = gitignoreSource ../..;
-
inherit modules;
+
inherit src modules;
doCheck = false;
+6
nix/gomod2nix.toml
···
[mod."github.com/cloudflare/circl"]
version = "v1.6.2-0.20250618153321-aa837fd1539d"
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
+
[mod."github.com/cloudflare/cloudflare-go"]
+
version = "v0.115.0"
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
[mod."github.com/containerd/errdefs"]
version = "v1.0.0"
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
[mod."github.com/golang/mock"]
version = "v1.6.0"
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
+
[mod."github.com/google/go-querystring"]
+
version = "v1.1.0"
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
[mod."github.com/google/uuid"]
version = "v1.6.0"
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+22
nix/modules/spindle.nix
···
example = "did:plc:qfpnj4og54vl56wngdriaxug";
description = "DID of owner (required)";
};
+
+
secrets = {
+
provider = mkOption {
+
type = types.str;
+
default = "sqlite";
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
+
};
+
+
openbao = {
+
proxyAddr = mkOption {
+
type = types.str;
+
default = "http://127.0.0.1:8200";
+
};
+
mount = mkOption {
+
type = types.str;
+
default = "spindle";
+
};
+
};
+
};
};
pipelines = {
···
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
];
+4
appview/ingester.go
···
if err != nil {
return fmt.Errorf("failed to update ACLs: %w", err)
}
+
+
l.Info("added spindle member")
case models.CommitOperationDelete:
rkey := e.Commit.RKey
···
if err = i.Enforcer.E.SavePolicy(); err != nil {
return fmt.Errorf("failed to save ACLs: %w", err)
}
+
+
l.Info("removed spindle member")
}
return nil
+4 -4
appview/spindles/spindles.go
···
if string(spindles[0].Owner) != user.Did {
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
return
}
member := r.FormValue("member")
if member == "" {
l.Error("empty member")
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
return
}
l = l.With("member", member)
···
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
if err != nil {
l.Error("failed to resolve member identity to handle", "err", err)
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
return
}
if memberId.Handle.IsInvalidHandle() {
l.Error("failed to resolve member identity to handle")
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
return
}
+15
spindle/db/db.go
···
unique(owner, name)
);
+
create table if not exists spindle_members (
+
-- identifiers for the record
+
id integer primary key autoincrement,
+
did text not null,
+
rkey text not null,
+
+
-- data
+
instance text not null,
+
subject text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique (did, instance, subject)
+
);
+
-- status event for a single workflow
create table if not exists events (
rkey text not null,
+59
spindle/db/member.go
···
+
package db
+
+
import (
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
type SpindleMember struct {
+
Id int
+
Did syntax.DID // owner of the record
+
Rkey string // rkey of the record
+
Instance string
+
Subject syntax.DID // the member being added
+
Created time.Time
+
}
+
+
func AddSpindleMember(db *DB, member SpindleMember) error {
+
_, err := db.Exec(
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
+
member.Did,
+
member.Rkey,
+
member.Instance,
+
member.Subject,
+
)
+
return err
+
}
+
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
+
_, err := db.Exec(
+
"delete from spindle_members where did = ? and rkey = ?",
+
owner_did,
+
rkey,
+
)
+
return err
+
}
+
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
+
query :=
+
`select id, did, rkey, instance, subject, created
+
from spindle_members
+
where did = ? and rkey = ?`
+
+
var member SpindleMember
+
var createdAt string
+
err := db.QueryRow(query, did, rkey).Scan(
+
&member.Id,
+
&member.Did,
+
&member.Rkey,
+
&member.Instance,
+
&member.Subject,
+
&createdAt,
+
)
+
if err != nil {
+
return nil, err
+
}
+
+
return &member, nil
+
}
+39 -4
spindle/ingester.go
···
"encoding/json"
"errors"
"fmt"
+
"time"
"tangled.sh/tangled.sh/core/api/tangled"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/spindle/db"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
···
}
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
-
did := e.Did
var err error
+
did := e.Did
+
rkey := e.Commit.RKey
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
···
}
domain := s.cfg.Server.Hostname
-
if s.cfg.Server.Dev {
-
domain = s.cfg.Server.ListenAddr
-
}
recordInstance := record.Instance
if recordInstance != domain {
···
return fmt.Errorf("failed to enforce permissions: %w", err)
}
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
+
Did: syntax.DID(did),
+
Rkey: rkey,
+
Instance: recordInstance,
+
Subject: syntax.DID(record.Subject),
+
Created: time.Now(),
+
}); err != nil {
+
l.Error("failed to add member", "error", err)
+
return fmt.Errorf("failed to add member: %w", err)
+
}
+
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
l.Error("failed to add member", "error", err)
return fmt.Errorf("failed to add member: %w", err)
···
return nil
+
case models.CommitOperationDelete:
+
record, err := db.GetSpindleMember(s.db, did, rkey)
+
if err != nil {
+
l.Error("failed to find member", "error", err)
+
return fmt.Errorf("failed to find member: %w", err)
+
}
+
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
+
l.Error("failed to remove member", "error", err)
+
return fmt.Errorf("failed to remove member: %w", err)
+
}
+
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
+
l.Error("failed to add member", "error", err)
+
return fmt.Errorf("failed to add member: %w", err)
+
}
+
l.Info("added member from firehose", "member", record.Subject)
+
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
+
l.Error("failed to add did", "error", err)
+
return fmt.Errorf("failed to add did: %w", err)
+
}
+
s.jc.RemoveDid(record.Subject.String())
+
}
return nil
}
+1 -1
nix/pkgs/lexgen.nix
···
version = "0.1.0";
src = indigo;
subPackages = ["cmd/lexgen"];
-
vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs=";
+
vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw=";
doCheck = false;
}
+3
appview/config/config.go
···
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
Dev bool `env:"DEV, default=false"`
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
+
+
// temporarily, to add users to default spindle
+
AppPassword string `env:"APP_PASSWORD"`
}
type OAuthConfig struct {
+141
appview/oauth/handler/handler.go
···
package oauth
import (
+
"bytes"
+
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
+
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/posthog/posthog-go"
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
···
"tangled.sh/tangled.sh/core/idresolver"
"tangled.sh/tangled.sh/core/knotclient"
"tangled.sh/tangled.sh/core/rbac"
+
"tangled.sh/tangled.sh/core/tid"
)
const (
···
log.Println("session saved successfully")
go o.addToDefaultKnot(oauthRequest.Did)
+
go o.addToDefaultSpindle(oauthRequest.Did)
if !o.config.Core.Dev {
err = o.posthog.Enqueue(posthog.Capture{
···
return pubKey, nil
}
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
+
// use the tangled.sh app password to get an accessJwt
+
// and create an sh.tangled.spindle.member record with that
+
+
defaultSpindle := "spindle.tangled.sh"
+
appPassword := o.config.Core.AppPassword
+
+
spindleMembers, err := db.GetSpindleMembers(
+
o.db,
+
db.FilterEq("instance", "spindle.tangled.sh"),
+
db.FilterEq("subject", did),
+
)
+
if err != nil {
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
+
return
+
}
+
+
if len(spindleMembers) != 0 {
+
log.Printf("did %s is already a member of the default spindle", did)
+
return
+
}
+
+
// TODO: hardcoded tangled handle and did for now
+
tangledHandle := "tangled.sh"
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
+
+
if appPassword == "" {
+
log.Println("no app password configured, skipping spindle member addition")
+
return
+
}
+
+
log.Printf("adding %s to default spindle", did)
+
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
+
if err != nil {
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
+
return
+
}
+
+
pdsEndpoint := resolved.PDSEndpoint()
+
if pdsEndpoint == "" {
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
+
return
+
}
+
+
sessionPayload := map[string]string{
+
"identifier": tangledHandle,
+
"password": appPassword,
+
}
+
sessionBytes, err := json.Marshal(sessionPayload)
+
if err != nil {
+
log.Printf("failed to marshal session payload: %v", err)
+
return
+
}
+
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
+
if err != nil {
+
log.Printf("failed to create session request: %v", err)
+
return
+
}
+
sessionReq.Header.Set("Content-Type", "application/json")
+
+
client := &http.Client{Timeout: 30 * time.Second}
+
sessionResp, err := client.Do(sessionReq)
+
if err != nil {
+
log.Printf("failed to create session: %v", err)
+
return
+
}
+
defer sessionResp.Body.Close()
+
+
if sessionResp.StatusCode != http.StatusOK {
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
+
return
+
}
+
+
var session struct {
+
AccessJwt string `json:"accessJwt"`
+
}
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
+
log.Printf("failed to decode session response: %v", err)
+
return
+
}
+
+
record := tangled.SpindleMember{
+
LexiconTypeID: "sh.tangled.spindle.member",
+
Subject: did,
+
Instance: defaultSpindle,
+
CreatedAt: time.Now().Format(time.RFC3339),
+
}
+
+
recordBytes, err := json.Marshal(record)
+
if err != nil {
+
log.Printf("failed to marshal spindle member record: %v", err)
+
return
+
}
+
+
payload := map[string]interface{}{
+
"repo": tangledDid,
+
"collection": tangled.SpindleMemberNSID,
+
"rkey": tid.TID(),
+
"record": json.RawMessage(recordBytes),
+
}
+
+
payloadBytes, err := json.Marshal(payload)
+
if err != nil {
+
log.Printf("failed to marshal request payload: %v", err)
+
return
+
}
+
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
+
if err != nil {
+
log.Printf("failed to create HTTP request: %v", err)
+
return
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
+
+
resp, err := client.Do(req)
+
if err != nil {
+
log.Printf("failed to add user to default spindle: %v", err)
+
return
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
+
return
+
}
+
+
log.Printf("successfully added %s to default spindle", did)
+
}
+
func (o *OAuthHandler) addToDefaultKnot(did string) {
defaultKnot := "knot1.tangled.sh"
+1 -1
appview/signup/signup.go
···
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
Type: "TXT",
Name: "_atproto." + username,
-
Content: "did=" + did,
+
Content: fmt.Sprintf(`"did=%s"`, did),
TTL: 6400,
Proxied: false,
})
+1 -1
spindle/secrets/openbao.go
···
return ErrKeyNotFound
}
-
err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath)
+
err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath)
if err != nil {
return fmt.Errorf("failed to delete secret from openbao: %w", err)
}
+1 -1
docs/knot-hosting.md
···
systemctl start knotserver
```
-
The last step is to configure a reverse proxy like Nginx or Caddy to front yourself
+
The last step is to configure a reverse proxy like Nginx or Caddy to front your
knot. Here's an example configuration for Nginx:
```
+1 -1
docs/spindle/openbao.md
···
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
# Generate secret ID
-
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
echo "Role ID: $ROLE_ID"
echo "Secret ID: $SECRET_ID"
+9 -10
docs/hacking.md
···
`nixosConfiguration` to do so.
To begin, head to `http://localhost:3000/knots` in the browser
-
and generate a knot secret. Replace the existing secret in
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
-
secret.
+
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
+
don't lose it.
You can now start a lightweight NixOS VM using
`nixos-shell` like so:
···
## running a spindle
-
Be sure to change the `owner` field for the spindle in
-
`nix/vm.nix` to your own DID. The above VM should already
-
be running a spindle on `localhost:6555`. You can head to
-
the spindle dashboard on `http://localhost:3000/spindles`,
-
and register a spindle with hostname `localhost:6555`. It
-
should instantly be verified. You can then configure each
-
repository to use this spindle and run CI jobs.
+
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
+
The above VM should already be running a spindle on `localhost:6555`.
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
+
and register a spindle with hostname `localhost:6555`. It should instantly
+
be verified. You can then configure each repository to use this spindle
+
and run CI jobs.
Of interest when debugging spindles:
+1 -1
nix/vm.nix
···
];
};
services.getty.autologinUser = "root";
-
environment.systemPackages = with pkgs; [curl vim git];
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
systemd.tmpfiles.rules = let
u = config.services.tangled-knot.gitUser;
g = config.services.tangled-knot.gitUser;
+2 -2
appview/db/db.go
···
kind := rv.Kind()
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
-
if kind == reflect.Slice || kind == reflect.Array {
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
if rv.Len() == 0 {
// always false
return "1 = 0"
···
func (f filter) Arg() []any {
rv := reflect.ValueOf(f.arg)
kind := rv.Kind()
-
if kind == reflect.Slice || kind == reflect.Array {
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
if rv.Len() == 0 {
return nil
}
+3 -2
appview/pages/templates/repo/tree.html
···
{{ if .IsFile }}
{{ $icon = "file" }}
-
{{ $iconStyle = "size-4" }}
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
{{ end }}
<a href="{{ $link }}" class="{{ $linkstyle }}">
<div class="flex items-center gap-2">
-
{{ i $icon $iconStyle }}{{ .Name }}
+
{{ i $icon $iconStyle }}
+
<span class="truncate">{{ .Name }}</span>
</div>
</a>
</div>
+23
nix/pkgs/appview-static-files.nix
···
+
{
+
runCommandLocal,
+
htmx-src,
+
htmx-ws-src,
+
lucide-src,
+
inter-fonts-src,
+
ibm-plex-mono-src,
+
sqlite-lib,
+
tailwindcss,
+
src,
+
}:
+
runCommandLocal "appview-static-files" {} ''
+
mkdir -p $out/{fonts,icons} && cd $out
+
cp -f ${htmx-src} htmx.min.js
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
+
cp -rf ${lucide-src}/*.svg icons/
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
+
# for whatever reason (produces broken css), so we are doing this instead
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
+
''
+5
appview/strings/strings.go
···
w.WriteHeader(http.StatusInternalServerError)
return
}
+
if len(strings) < 1 {
+
l.Error("string not found")
+
s.Pages.Error404(w)
+
return
+
}
if len(strings) != 1 {
l.Error("incorrect number of records returned", "len(strings)", len(strings))
w.WriteHeader(http.StatusInternalServerError)
-168
appview/pages/templates/repo/settings.html
···
-
{{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }}
-
-
{{ define "repoContent" }}
-
{{ template "collaboratorSettings" . }}
-
{{ template "branchSettings" . }}
-
{{ template "dangerZone" . }}
-
{{ template "spindleSelector" . }}
-
{{ template "spindleSecrets" . }}
-
{{ end }}
-
-
{{ define "collaboratorSettings" }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Collaborators
-
</header>
-
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
-
{{ range .Collaborators }}
-
<div id="collaborator" class="mb-2">
-
<a
-
href="/{{ didOrHandle .Did .Handle }}"
-
class="no-underline hover:underline text-black dark:text-white"
-
>
-
{{ didOrHandle .Did .Handle }}
-
</a>
-
<div>
-
<span class="text-sm text-gray-500 dark:text-gray-400">
-
{{ .Role }}
-
</span>
-
</div>
-
</div>
-
{{ end }}
-
</div>
-
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
-
class="group"
-
>
-
<label for="collaborator" class="dark:text-white">
-
add collaborator
-
</label>
-
<input
-
type="text"
-
id="collaborator"
-
name="collaborator"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="enter did or handle">
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
-
<span>add</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "dangerZone" }}
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
-
<form
-
hx-confirm="Are you sure you want to delete this repository?"
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
-
class="mt-6"
-
hx-indicator="#delete-repo-spinner">
-
<label for="branch">delete repository</label>
-
<button class="btn my-2 flex items-center" type="text">
-
<span>delete</span>
-
<span id="delete-repo-spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
<span>
-
Deleting a repository is irreversible and permanent.
-
</span>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "branchSettings" }}
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
-
<label for="branch">default branch</label>
-
<div class="flex gap-2 items-center">
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
-
<option value="" disabled selected >
-
Choose a default branch
-
</option>
-
{{ range .Branches }}
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
-
{{ .Name }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
{{ end }}
-
-
{{ define "spindleSelector" }}
-
{{ if .RepoInfo.Roles.IsOwner }}
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
-
<label for="spindle">spindle</label>
-
<div class="flex gap-2 items-center">
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
-
<option value="" selected >
-
None
-
</option>
-
{{ range .Spindles }}
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
-
{{ . }}
-
</option>
-
{{ end }}
-
</select>
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
-
<span>save</span>
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</button>
-
</div>
-
</form>
-
{{ end }}
-
{{ end }}
-
-
{{ define "spindleSecrets" }}
-
{{ if $.CurrentSpindle }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
-
Secrets
-
</header>
-
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
-
{{ range $idx, $secret := .Secrets }}
-
{{ with $secret }}
-
<div id="secret-{{$idx}}" class="mb-2">
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
-
</div>
-
{{ end }}
-
{{ end }}
-
</div>
-
<form
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
-
class="mt-6"
-
hx-indicator="#add-secret-spinner">
-
<label for="key">secret key</label>
-
<input
-
type="text"
-
id="key"
-
name="key"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="SECRET_KEY" />
-
<label for="value">secret value</label>
-
<input
-
type="text"
-
id="value"
-
name="value"
-
required
-
class="dark:bg-gray-700 dark:text-white"
-
placeholder="SECRET VALUE" />
-
-
<button class="btn my-2 flex items-center" type="text">
-
<span>add</span>
-
<span id="add-secret-spinner" class="group">
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
-
</span>
-
</button>
-
</form>
-
{{ end }}
-
{{ end }}
+5 -1
appview/pages/templates/repo/pipelines/workflow.html
···
{{ define "sidebar" }}
{{ $active := .Workflow }}
+
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
+
{{ with .Pipeline }}
{{ $id := .Id }}
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
{{ range $name, $all := .Statuses }}
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
<div
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
{{ $lastStatus := $all.Latest }}
{{ $kind := $lastStatus.Status.String }}
+242 -2
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 3
+
fieldCount := 4
if t.Environment == nil {
fieldCount--
+
if t.Oidcs_tokens == nil {
+
fieldCount--
+
}
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
···
+
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
+
if t.Oidcs_tokens != nil {
+
+
if len("oidcs_tokens") > 1000000 {
+
return xerrors.Errorf("Value in field \"oidcs_tokens\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("oidcs_tokens"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("oidcs_tokens")); err != nil {
+
return err
+
}
+
+
if len(t.Oidcs_tokens) > 8192 {
+
return xerrors.Errorf("Slice value in field t.Oidcs_tokens was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Oidcs_tokens))); err != nil {
+
return err
+
}
+
for _, v := range t.Oidcs_tokens {
+
if err := v.MarshalCBOR(cw); err != nil {
+
return err
+
}
+
+
}
+
}
return nil
···
n := extra
-
nameBuf := make([]byte, 11)
+
nameBuf := make([]byte, 12)
for i := uint64(0); i < n; i++ {
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
if err != nil {
···
+
// t.Oidcs_tokens ([]*tangled.Pipeline_Step_Oidcs_tokens_Elem) (slice)
+
case "oidcs_tokens":
+
+
maj, extra, err = cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
+
if extra > 8192 {
+
return fmt.Errorf("t.Oidcs_tokens: array too large (%d)", extra)
+
}
+
+
if maj != cbg.MajArray {
+
return fmt.Errorf("expected cbor array")
+
}
+
+
if extra > 0 {
+
t.Oidcs_tokens = make([]*Pipeline_Step_Oidcs_tokens_Elem, extra)
+
}
+
+
for i := 0; i < int(extra); i++ {
+
{
+
var maj byte
+
var extra uint64
+
var err error
+
_ = maj
+
_ = extra
+
_ = err
+
+
{
+
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
t.Oidcs_tokens[i] = new(Pipeline_Step_Oidcs_tokens_Elem)
+
if err := t.Oidcs_tokens[i].UnmarshalCBOR(cr); err != nil {
+
return xerrors.Errorf("unmarshaling t.Oidcs_tokens[i] pointer: %w", err)
+
}
+
}
+
+
}
+
+
}
+
}
+
+
default:
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
}
+
}
+
+
return nil
+
}
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) MarshalCBOR(w io.Writer) error {
+
if t == nil {
+
_, err := w.Write(cbg.CborNull)
+
return err
+
}
+
+
cw := cbg.NewCborWriter(w)
+
fieldCount := 2
+
+
if t.Aud == nil {
+
fieldCount--
+
}
+
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
+
return err
+
}
+
+
// t.Aud (string) (string)
+
if t.Aud != nil {
+
+
if len("aud") > 1000000 {
+
return xerrors.Errorf("Value in field \"aud\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("aud"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("aud")); err != nil {
+
return err
+
}
+
+
if t.Aud == nil {
+
if _, err := cw.Write(cbg.CborNull); err != nil {
+
return err
+
}
+
} else {
+
if len(*t.Aud) > 1000000 {
+
return xerrors.Errorf("Value in field t.Aud was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Aud))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(*t.Aud)); err != nil {
+
return err
+
}
+
}
+
}
+
+
// t.Name (string) (string)
+
if len("name") > 1000000 {
+
return xerrors.Errorf("Value in field \"name\" was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string("name")); err != nil {
+
return err
+
}
+
+
if len(t.Name) > 1000000 {
+
return xerrors.Errorf("Value in field t.Name was too long")
+
}
+
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil {
+
return err
+
}
+
if _, err := cw.WriteString(string(t.Name)); err != nil {
+
return err
+
}
+
return nil
+
}
+
+
func (t *Pipeline_Step_Oidcs_tokens_Elem) UnmarshalCBOR(r io.Reader) (err error) {
+
*t = Pipeline_Step_Oidcs_tokens_Elem{}
+
+
cr := cbg.NewCborReader(r)
+
+
maj, extra, err := cr.ReadHeader()
+
if err != nil {
+
return err
+
}
+
defer func() {
+
if err == io.EOF {
+
err = io.ErrUnexpectedEOF
+
}
+
}()
+
+
if maj != cbg.MajMap {
+
return fmt.Errorf("cbor input should be of type map")
+
}
+
+
if extra > cbg.MaxLength {
+
return fmt.Errorf("Pipeline_Step_Oidcs_tokens_Elem: map struct too large (%d)", extra)
+
}
+
+
n := extra
+
+
nameBuf := make([]byte, 4)
+
for i := uint64(0); i < n; i++ {
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
+
if err != nil {
+
return err
+
}
+
+
if !ok {
+
// Field doesn't exist on this type, so ignore it
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
+
return err
+
}
+
continue
+
}
+
+
switch string(nameBuf[:nameLen]) {
+
// t.Aud (string) (string)
+
case "aud":
+
+
{
+
b, err := cr.ReadByte()
+
if err != nil {
+
return err
+
}
+
if b != cbg.CborNull[0] {
+
if err := cr.UnreadByte(); err != nil {
+
return err
+
}
+
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Aud = (*string)(&sval)
+
}
+
}
+
// t.Name (string) (string)
+
case "name":
+
+
{
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
+
if err != nil {
+
return err
+
}
+
+
t.Name = string(sval)
+
}
default:
// Field doesn't exist on this type, so ignore it
+9 -3
api/tangled/tangledpipeline.go
···
// Pipeline_Step is a "step" in the sh.tangled.pipeline schema.
type Pipeline_Step struct {
-
Command string `json:"command" cborgen:"command"`
-
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
-
Name string `json:"name" cborgen:"name"`
+
Command string `json:"command" cborgen:"command"`
+
Environment []*Pipeline_Pair `json:"environment,omitempty" cborgen:"environment,omitempty"`
+
Name string `json:"name" cborgen:"name"`
+
Oidcs_tokens []*Pipeline_Step_Oidcs_tokens_Elem `json:"oidcs_tokens,omitempty" cborgen:"oidcs_tokens,omitempty"`
+
}
+
+
type Pipeline_Step_Oidcs_tokens_Elem struct {
+
Aud *string `json:"aud,omitempty" cborgen:"aud,omitempty"`
+
Name string `json:"name" cborgen:"name"`
}
// Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema.
+1
cmd/gen.go
···
tangled.Pipeline_PushTriggerData{},
tangled.PipelineStatus{},
tangled.Pipeline_Step{},
+
tangled.Pipeline_Step_Oidcs_tokens_Elem{},
tangled.Pipeline_TriggerMetadata{},
tangled.Pipeline_TriggerRepo{},
tangled.Pipeline_Workflow{},
+17
lexicons/pipeline/pipeline.json
···
"type": "ref",
"ref": "#pair"
}
+
},
+
"oidcs_tokens": {
+
"type": "array",
+
"items": {
+
"type": "object",
+
"required": [
+
"name"
+
],
+
"properties": {
+
"name": {
+
"type": "string"
+
},
+
"aud": {
+
"type": "string"
+
}
+
}
+
}
}
}
},
+15 -3
spindle/engine/engine.go
···
"tangled.sh/tangled.sh/core/spindle/config"
"tangled.sh/tangled.sh/core/spindle/db"
"tangled.sh/tangled.sh/core/spindle/models"
+
"tangled.sh/tangled.sh/core/spindle/oidc"
"tangled.sh/tangled.sh/core/spindle/secrets"
)
···
n *notifier.Notifier
cfg *config.Config
vault secrets.Manager
+
oidc oidc.OidcTokenGenerator
cleanupMu sync.Mutex
cleanup map[string][]cleanupFunc
}
-
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager) (*Engine, error) {
+
func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier, vault secrets.Manager, oidc *oidc.OidcTokenGenerator) (*Engine, error) {
dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
···
n: n,
cfg: cfg,
vault: vault,
+
oidc: *oidc,
}
e.cleanup = make(map[string][]cleanupFunc)
···
ctx, cancel := context.WithTimeout(ctx, workflowTimeout)
defer cancel()
-
err = e.StartSteps(ctx, wid, w, allSecrets)
+
err = e.StartSteps(ctx, wid, w, allSecrets, pipeline, pipelineId)
if err != nil {
if errors.Is(err, ErrTimedOut) {
dbErr := e.db.StatusTimeout(wid, e.n)
···
// ONLY marks pipeline as failed if container's exit code is non-zero.
// All other errors are bubbled up.
// Fixed version of the step execution logic
-
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret) error {
+
func (e *Engine) StartSteps(ctx context.Context, wid models.WorkflowId, w models.Workflow, secrets []secrets.UnlockedSecret, pipeline *models.Pipeline, pipelineId models.PipelineId) error {
workflowEnvs := ConstructEnvs(w.Environment)
for _, s := range secrets {
workflowEnvs.AddEnv(s.Key, s.Value)
···
envs.AddEnv("HOME", workspaceDir)
e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice())
+
for _, t := range step.OidcTokens {
+
token, err := e.oidc.CreateToken(t, pipelineId, pipeline.RepoOwner, pipeline.RepoName)
+
if err != nil {
+
e.l.Error("failed to get OIDC token", "error", err, "token", t.Name)
+
return fmt.Errorf("getting OIDC token: %w", err)
+
}
+
envs.AddEnv(t.Name, token)
+
}
+
hostConfig := hostConfig(wid)
resp, err := e.docker.ContainerCreate(ctx, &container.Config{
Image: w.Image,
+14
spindle/models/pipeline.go
···
Name string
Environment map[string]string
Kind StepKind
+
OidcTokens []OidcToken
}
type StepKind int
···
StepKindUser
)
+
type OidcToken struct {
+
Name string
+
Aud *string
+
}
+
type Workflow struct {
Steps []Step
Environment map[string]string
···
sstep.Name = tstep.Name
sstep.Kind = StepKindUser
swf.Steps = append(swf.Steps, sstep)
+
+
sstep.OidcTokens = make([]OidcToken, 0, len(tstep.Oidcs_tokens))
+
for _, ttoken := range tstep.Oidcs_tokens {
+
sstep.OidcTokens = append(sstep.OidcTokens, OidcToken{
+
Name: ttoken.Name,
+
Aud: ttoken.Aud,
+
})
+
}
}
swf.Name = twf.Name
swf.Environment = workflowEnvToMap(twf.Environment)
+329
spindle/oidc/oidc.go
···
+
package oidc
+
+
import (
+
"crypto/ecdsa"
+
"crypto/elliptic"
+
"crypto/rand"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"time"
+
+
"github.com/lestrrat-go/jwx/v2/jwa"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
"github.com/lestrrat-go/jwx/v2/jwt"
+
"tangled.sh/tangled.sh/core/spindle/models"
+
)
+
+
const JWKSPath = "/.well-known/jwks.json"
+
+
// OidcKeyPair represents an OIDC key pair with both private and public keys
+
type OidcKeyPair struct {
+
privateKey *ecdsa.PrivateKey
+
publicKey *ecdsa.PublicKey
+
keyID string
+
jwkKey jwk.Key
+
}
+
+
// OidcTokenGenerator handles OIDC token generation and key management with rotation
+
type OidcTokenGenerator struct {
+
currentKeyPair *OidcKeyPair
+
nextKeyPair *OidcKeyPair
+
l *slog.Logger
+
issuer string
+
}
+
+
// NewOidcTokenGenerator creates a new OIDC token generator with in-memory key management
+
func NewOidcTokenGenerator(issuer string) (*OidcTokenGenerator, error) {
+
// Create new keys
+
currentKeyPair, err := NewOidcKeyPair()
+
if err != nil {
+
return nil, fmt.Errorf("failed to generate initial current key pair: %w", err)
+
}
+
+
return &OidcTokenGenerator{
+
issuer: issuer,
+
currentKeyPair: currentKeyPair,
+
}, nil
+
}
+
+
// NewOidcKeyPair generates a new ECDSA key pair for OIDC token signing
+
func NewOidcKeyPair() (*OidcKeyPair, error) {
+
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+
if err != nil {
+
return nil, fmt.Errorf("failed to generate ECDSA key: %w", err)
+
}
+
+
keyID := fmt.Sprintf("spindle-%d", time.Now().Unix())
+
+
// Create JWK from the private key
+
jwkKey, err := jwk.FromRaw(privKey)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create JWK from private key: %w", err)
+
}
+
+
// Set the key ID
+
if err := jwkKey.Set(jwk.KeyIDKey, keyID); err != nil {
+
return nil, fmt.Errorf("failed to set key ID: %w", err)
+
}
+
+
// Set algorithm
+
if err := jwkKey.Set(jwk.AlgorithmKey, jwa.ES256); err != nil {
+
return nil, fmt.Errorf("failed to set algorithm: %w", err)
+
}
+
+
// Set usage
+
if err := jwkKey.Set(jwk.KeyUsageKey, "sig"); err != nil {
+
return nil, fmt.Errorf("failed to set key usage: %w", err)
+
}
+
+
return &OidcKeyPair{
+
privateKey: privKey,
+
publicKey: &privKey.PublicKey,
+
keyID: keyID,
+
jwkKey: jwkKey,
+
}, nil
+
}
+
+
// LoadOidcKeyPair loads an existing key pair from JWK JSON
+
func LoadOidcKeyPair(jwkJSON []byte) (*OidcKeyPair, error) {
+
jwkKey, err := jwk.ParseKey(jwkJSON)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse JWK: %w", err)
+
}
+
+
var privKey *ecdsa.PrivateKey
+
if err := jwkKey.Raw(&privKey); err != nil {
+
return nil, fmt.Errorf("failed to extract private key: %w", err)
+
}
+
+
keyID, ok := jwkKey.Get(jwk.KeyIDKey)
+
if !ok {
+
return nil, fmt.Errorf("JWK missing key ID")
+
}
+
+
keyIDStr, ok := keyID.(string)
+
if !ok {
+
return nil, fmt.Errorf("JWK key ID is not a string")
+
}
+
+
return &OidcKeyPair{
+
privateKey: privKey,
+
publicKey: &privKey.PublicKey,
+
keyID: keyIDStr,
+
jwkKey: jwkKey,
+
}, nil
+
}
+
+
// GetKeyID returns the key ID
+
func (k *OidcKeyPair) GetKeyID() string {
+
return k.keyID
+
}
+
+
// RotateKeys performs key rotation: generates new next key, moves next to current
+
func (g *OidcTokenGenerator) RotateKeys() error {
+
// Generate a new key pair for the next key
+
newNextKeyPair, err := NewOidcKeyPair()
+
if err != nil {
+
return fmt.Errorf("failed to generate new next key pair: %w", err)
+
}
+
+
// Perform rotation: next becomes current, new key becomes next
+
g.currentKeyPair = g.nextKeyPair
+
g.nextKeyPair = newNextKeyPair
+
+
// If we don't have a current key (first time setup), use the new key
+
if g.currentKeyPair == nil {
+
g.currentKeyPair = newNextKeyPair
+
// Generate another new key for next
+
g.nextKeyPair, err = NewOidcKeyPair()
+
if err != nil {
+
return fmt.Errorf("failed to generate next key pair for first setup: %w", err)
+
}
+
}
+
+
return nil
+
}
+
+
func (g *OidcTokenGenerator) GetCurrentKeyID() string {
+
if g.currentKeyPair == nil {
+
return ""
+
}
+
return g.currentKeyPair.GetKeyID()
+
}
+
+
// GetNextKeyID returns the next key's ID
+
func (g *OidcTokenGenerator) GetNextKeyID() string {
+
if g.nextKeyPair == nil {
+
return ""
+
}
+
return g.nextKeyPair.GetKeyID()
+
}
+
+
// HasKeys returns true if the generator has at least a current key
+
func (g *OidcTokenGenerator) HasKeys() bool {
+
return g.currentKeyPair != nil
+
}
+
+
// OidcClaims represents the claims in an OIDC token
+
type OidcClaims struct {
+
// Standard JWT claims
+
Issuer string `json:"iss"`
+
Subject string `json:"sub"`
+
Audience string `json:"aud"`
+
ExpiresAt int64 `json:"exp"`
+
NotBefore int64 `json:"nbf"`
+
IssuedAt int64 `json:"iat"`
+
JWTID string `json:"jti"`
+
}
+
+
// CreateToken creates a signed JWT token for the given OidcToken and pipeline context
+
func (g *OidcTokenGenerator) CreateToken(
+
oidcToken models.OidcToken,
+
pipelineId models.PipelineId,
+
repoOwner, repoName string,
+
) (string, error) {
+
now := time.Now()
+
exp := now.Add(5 * time.Minute)
+
+
// Determine audience - use the provided audience or default to issuer
+
audience := fmt.Sprintf(g.issuer)
+
if oidcToken.Aud != nil && *oidcToken.Aud != "" {
+
audience = *oidcToken.Aud
+
}
+
+
pipelineUri := pipelineId.AtUri()
+
+
// Create claims
+
claims := OidcClaims{
+
Issuer: g.issuer,
+
// Hardcode the did as did:web of the issuer. At some point knots will have their own DIDs which will be used here
+
Subject: pipelineUri.String(),
+
Audience: audience,
+
ExpiresAt: exp.Unix(),
+
NotBefore: now.Unix(),
+
IssuedAt: now.Unix(),
+
// Repo owner, name, and id should be global unique but we add timestamp to ensure uniqueness
+
JWTID: fmt.Sprintf("%s/%s-%s-%d", repoOwner, repoName, pipelineUri.RecordKey(), now.Unix()),
+
}
+
+
// Create JWT token
+
token := jwt.New()
+
+
// Set all claims
+
if err := token.Set(jwt.IssuerKey, claims.Issuer); err != nil {
+
return "", fmt.Errorf("failed to set issuer: %w", err)
+
}
+
if err := token.Set(jwt.SubjectKey, claims.Subject); err != nil {
+
return "", fmt.Errorf("failed to set subject: %w", err)
+
}
+
if err := token.Set(jwt.AudienceKey, claims.Audience); err != nil {
+
return "", fmt.Errorf("failed to set audience: %w", err)
+
}
+
if err := token.Set(jwt.ExpirationKey, claims.ExpiresAt); err != nil {
+
return "", fmt.Errorf("failed to set expiration: %w", err)
+
}
+
if err := token.Set(jwt.NotBeforeKey, claims.NotBefore); err != nil {
+
return "", fmt.Errorf("failed to set not before: %w", err)
+
}
+
if err := token.Set(jwt.IssuedAtKey, claims.IssuedAt); err != nil {
+
return "", fmt.Errorf("failed to set issued at: %w", err)
+
}
+
if err := token.Set(jwt.JwtIDKey, claims.JWTID); err != nil {
+
return "", fmt.Errorf("failed to set JWT ID: %w", err)
+
}
+
+
// Sign the token with the current key
+
if g.currentKeyPair == nil {
+
return "", fmt.Errorf("no current key pair available for signing")
+
}
+
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256, g.currentKeyPair.jwkKey))
+
if err != nil {
+
return "", fmt.Errorf("failed to sign token: %w", err)
+
}
+
+
return string(signedToken), nil
+
}
+
+
// JWKSHandler serves the JWKS endpoint as an HTTP handler
+
func (g *OidcTokenGenerator) JWKSHandler(w http.ResponseWriter, r *http.Request) {
+
var keys []jwk.Key
+
+
// Add current key if available
+
if g.currentKeyPair != nil {
+
pubJWK, err := jwk.PublicKeyOf(g.currentKeyPair.jwkKey)
+
if err != nil {
+
http.Error(w, fmt.Sprintf("failed to extract current public key from JWK: %v", err), http.StatusInternalServerError)
+
return
+
}
+
keys = append(keys, pubJWK)
+
}
+
+
// Add next key if available
+
if g.nextKeyPair != nil {
+
pubJWK, err := jwk.PublicKeyOf(g.nextKeyPair.jwkKey)
+
if err != nil {
+
http.Error(w, fmt.Sprintf("failed to extract next public key from JWK: %v", err), http.StatusInternalServerError)
+
return
+
}
+
keys = append(keys, pubJWK)
+
}
+
+
if len(keys) == 0 {
+
http.Error(w, "no keys available for JWKS", http.StatusInternalServerError)
+
return
+
}
+
+
jwks := map[string]interface{}{
+
"keys": keys,
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(jwks); err != nil {
+
http.Error(w, fmt.Sprintf("failed to encode JWKS: %v", err), http.StatusInternalServerError)
+
}
+
}
+
+
// DiscoveryHandler serves the OIDC discovery endpoint for JWKS
+
func (g *OidcTokenGenerator) DiscoveryHandler(w http.ResponseWriter, r *http.Request) {
+
claimsSupported := []string{
+
"iss",
+
"sub",
+
"aud",
+
"exp",
+
"nbf",
+
"iat",
+
"jti",
+
}
+
+
responseTypesSupported := []string{
+
"id_token",
+
}
+
+
subjectTypesSupported := []string{
+
"public",
+
}
+
+
idTokenSigningAlgValuesSupported := []string{
+
jwa.RS256.String(),
+
}
+
+
scopesSupported := []string{
+
"openid",
+
}
+
+
discovery := map[string]interface{}{
+
"issuer": g.issuer,
+
"jwks_uri": fmt.Sprintf("%s%s", g.issuer, JWKSPath),
+
"claims_supported": claimsSupported,
+
"response_types_supported": responseTypesSupported,
+
"subject_types_supported": subjectTypesSupported,
+
"id_token_signing_alg_values_supported": idTokenSigningAlgValuesSupported,
+
"scopes_supported": scopesSupported,
+
}
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(discovery); err != nil {
+
http.Error(w, fmt.Sprintf("failed to encode discovery document: %v", err), http.StatusInternalServerError)
+
}
+
}