Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ go-cli/go-cli
go-m2m/go-m2m
go-webservice/go-webservice
go-jwks/go-jwks
go-jwks-multi/go-jwks-multi
go-oidc/go-oidc
.env
17 changes: 11 additions & 6 deletions go-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,26 @@ func main() {
}

func maskToken(s string) string {
if s == "" {
return ""
}
if len(s) <= 8 {
return "****"
}
return s[:8] + "..."
}

func printTokenInfo(ctx context.Context, client *oauth.Client, token *oauth.Token) {
info, err := client.UserInfo(ctx, token.AccessToken)
if err != nil {
fmt.Printf("Token: %s (UserInfo error: %v)\n", maskToken(token.AccessToken), err)
return
// UserInfo is best-effort: a transient failure here (5xx, timeout) must
// not suppress the token and introspection details this command exists
// to print, so report it and keep going rather than returning early.
if info, err := client.UserInfo(ctx, token.AccessToken); err != nil {
fmt.Printf("UserInfo error: %v\n", err)
} else {
fmt.Printf("User: %s (%s)\n", info.Name, info.Email)
fmt.Printf("Subject: %s\n", info.Sub)
}

fmt.Printf("User: %s (%s)\n", info.Name, info.Email)
fmt.Printf("Subject: %s\n", info.Sub)
fmt.Printf("Access Token: %s\n", maskToken(token.AccessToken))
fmt.Printf("Refresh Token: %s\n", maskToken(token.RefreshToken))
fmt.Printf("Token Type: %s\n", token.TokenType)
Expand Down
29 changes: 15 additions & 14 deletions go-jwks-multi/testissuer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ Spins up two HTTP issuers that **sign your test tokens locally** so you can exer

## What you get

| Issuer | URL | Default allowed domains |
| ------ | ------------------------- | -------------------------- |
| auth-a | `http://127.0.0.1:9001` | `oa`, `hwrd` |
| auth-b | `http://127.0.0.1:9002` | `swrd`, `cdomain` |
| Issuer | URL | Default allowed domains |
| ------ | ----------------------- | ----------------------- |
| auth-a | `http://127.0.0.1:9001` | `oa`, `hwrd` |
| auth-b | `http://127.0.0.1:9002` | `swrd`, `cdomain` |

Each issuer:

Expand Down Expand Up @@ -47,16 +47,17 @@ go run .

## `/sign` query parameters

| Param | Default | Notes |
| ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `aud` | `https://api.example.com` | Sets the `aud` claim |
| `sub` | `test-user-1` | Sets the `sub` claim |
| `scope` | `email profile` | Space-separated; URL-encode space as `+` |
| `client_id` | `test-client` | Sets the `client_id` claim |
| `domain` | (omitted) | Mints `<prefix>_domain` (default `extra_domain`) — omit to test fail-closed behavior |
| `sa` | (omitted) | Mints `<prefix>_service_account` (default `extra_service_account`) — omit to test fail-closed |
| `project` | (omitted) | Mints `<prefix>_project` (default `extra_project`) — omit to test fail-closed |
| `ttl` | `300` (seconds) | `exp` is `iat + ttl` |
| Param | Default | Notes |
| ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aud` | `https://api.example.com` | Sets the `aud` claim |
| `sub` | `test-user-1` | Sets the `sub` claim |
| `scope` | `email profile` | Space-separated; URL-encode space as `+` |
| `client_id` | `test-client` | Sets the `client_id` claim |
| `domain` | (omitted) | Mints `<prefix>_domain` (default `extra_domain`) — omit to test fail-closed behavior |
| `sa` | (omitted) | Mints `<prefix>_service_account` (default `extra_service_account`) — omit to test fail-closed |
| `project` | (omitted) | Mints `<prefix>_project` (default `extra_project`) — omit to test fail-closed |
| `uid` | (omitted) | Mints `<prefix>_uid` (default `extra_uid`) — the username for user-bearing tokens; surfaced in `/api/profile` and `/api/admin` (no `AccessRule` gates it) |
| `ttl` | `300` (seconds) | `exp` is `iat + ttl` |

`iss` is implicit — it's whichever port you call (`http://127.0.0.1:9001` for auth-a, `9002` for auth-b).

Expand Down
18 changes: 12 additions & 6 deletions go-jwks-multi/testissuer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
// auto-discover and cache the public key.
// - Each issuer exposes a `/sign` endpoint that mints arbitrary JWTs
// signed by THAT issuer's key. You set `iss` implicitly by choosing
// the port; everything else (`aud`, `domain`, `sa`,
// `project`, `scope`, `sub`, `client_id`, `ttl`) is a query param.
// the port; everything else (`aud`, `domain`, `sa`, `project`, `uid`,
// `scope`, `sub`, `client_id`, `ttl`) is a query param.
//
// Why this exists: ../get-token.sh in ../../go-jwks/ talks to a real
// AuthGate via Client Credentials. For multi-issuer + multi-domain
Expand Down Expand Up @@ -78,10 +78,11 @@ type issuer struct {
// The resource server consuming these tokens must be configured with
// the same JWT_PRIVATE_CLAIM_PREFIX, otherwise its decoder lands these
// keys in Claims.Extras instead of the typed Domain/ServiceAccount/
// Project fields and any AccessRule covering them fails closed.
// Project/UID fields and any AccessRule covering them fails closed.
domainKey string
serviceAccountKey string
projectKey string
uidKey string
}

func newIssuer(name string, port int, privateClaimPrefix string) (*issuer, error) {
Expand Down Expand Up @@ -117,6 +118,7 @@ func newIssuer(name string, port int, privateClaimPrefix string) (*issuer, error
domainKey: privateClaimPrefix + "_domain",
serviceAccountKey: privateClaimPrefix + "_service_account",
projectKey: privateClaimPrefix + "_project",
uidKey: privateClaimPrefix + "_uid",
}, nil
}

Expand Down Expand Up @@ -163,6 +165,7 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
domain := q.Get("domain")
sa := q.Get("sa")
project := q.Get("project")
uid := q.Get("uid")
ttlSec, err := strconv.Atoi(def(q.Get("ttl"), "300"))
if err != nil || ttlSec <= 0 {
http.Error(w, "ttl must be a positive integer (seconds)", http.StatusBadRequest)
Expand Down Expand Up @@ -191,14 +194,17 @@ func (i *issuer) sign(w http.ResponseWriter, r *http.Request) {
if project != "" {
claims[i.projectKey] = project
}
if uid != "" {
claims[i.uidKey] = uid
}

token, err := jwt.Signed(i.signer).Claims(claims).Serialize()
if err != nil {
http.Error(w, "sign: "+err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[%s] signed: sub=%q aud=%q domain=%q sa=%q project=%q scope=%q ttl=%ds",
i.name, sub, aud, domain, sa, project, scope, ttlSec)
log.Printf("[%s] signed: sub=%q aud=%q domain=%q sa=%q project=%q uid=%q scope=%q ttl=%ds",
i.name, sub, aud, domain, sa, project, uid, scope, ttlSec)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, token)
}
Expand All @@ -208,7 +214,7 @@ func (i *issuer) index(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "test issuer %q at %s\n\nendpoints:\n"+
" GET /.well-known/openid-configuration\n"+
" GET /jwks.json\n"+
" GET /sign?aud=...&sub=...&domain=...&sa=...&project=...&scope=...&ttl=...\n",
" GET /sign?aud=...&sub=...&domain=...&sa=...&project=...&uid=...&scope=...&ttl=...\n",
Comment thread
appleboy marked this conversation as resolved.
i.name, i.baseURL)
}

Expand Down
14 changes: 7 additions & 7 deletions go-jwks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ sequenceDiagram

## Environment Variables

| Variable | Required | Description |
| -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ISSUER_URL` | Yes | AuthGate issuer URL — must match the `iss` claim and the `issuer` field of the discovery document |
| `EXPECTED_AUDIENCE` | \* | Required value in the `aud` claim. Mandatory unless `SKIP_AUDIENCE_CHECK=1` is set. |
| `SKIP_AUDIENCE_CHECK` | \* | Set to `1` to explicitly disable `aud` enforcement. Only use for issuers that don't emit `aud` on access tokens. |
| `JWT_PRIVATE_CLAIM_PREFIX` | No | Overrides the SDK default of `extra` for the server-attested claim prefix. Must agree byte-for-byte with the AuthGate server's `JWT_PRIVATE_CLAIM_PREFIX`. Leave unset to use the default — see below. |
| Variable | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ISSUER_URL` | Yes | AuthGate issuer URL — must match the `iss` claim and the `issuer` field of the discovery document |
| `EXPECTED_AUDIENCE` | \* | Required value in the `aud` claim. Mandatory unless `SKIP_AUDIENCE_CHECK=1` is set. |
| `SKIP_AUDIENCE_CHECK` | \* | Set to `1` to explicitly disable `aud` enforcement. Only use for issuers that don't emit `aud` on access tokens. |
| `JWT_PRIVATE_CLAIM_PREFIX` | No | Overrides the SDK default of `extra` for the server-attested claim prefix. Must agree byte-for-byte with the AuthGate server's `JWT_PRIVATE_CLAIM_PREFIX`. Leave unset to use the default — see below. |

\* Exactly one of `EXPECTED_AUDIENCE` or `SKIP_AUDIENCE_CHECK=1` must be set — the server refuses to start otherwise, so a forgotten audience never silently disables validation.

Expand Down Expand Up @@ -137,7 +137,7 @@ These rules live in `main()` as `jwksauth.AccessRule{...}` literals. The middlew

### Quick path: `get-token.sh`

A tiny helper that runs the OAuth 2.0 **Client Credentials** grant and prints an access token you can paste into `curl`. Reads `ISSUER_URL` / `CLIENT_ID` / `CLIENT_SECRET` from this directory's `.env` (or the environment). Requires `curl` + `jq`; `--decode` also requires `base64`.
A tiny helper that runs the OAuth 2.0 **Client Credentials** grant and prints an access token you can paste into `curl`. Reads `ISSUER_URL` / `CLIENT_ID` / `CLIENT_SECRET` from this directory's `.env` (or the environment). Requires `curl` + `jq` (`--decode` is done in `jq`, no `base64` needed).

```bash
# Print just the access_token
Expand Down
51 changes: 27 additions & 24 deletions go-jwks/get-token.sh
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,25 @@ EOF

die() { printf 'Error: %s\n' "$*" >&2; exit 1; }

# Decode a base64url-encoded segment (converts -/_ to +/, pads to multiple of 4).
b64url_decode() {
local s
s=$(printf '%s' "$1" | tr '_-' '/+')
case $((${#s} % 4)) in
2) s+="==" ;;
3) s+="=" ;;
esac
printf '%s' "$s" | base64 -d
}

# Pretty-print a JWT's header + payload as a single JSON object.
# Pretty-print a JWT's header + payload as a single JSON object. We decode in
# jq (already a hard dependency) rather than `base64 -d`, whose decode flag is
# -d on GNU but -D on older macOS. jq's @base64d only accepts the standard
# base64 alphabet, so b64urld first maps the JWT base64url alphabet (-_ -> +/)
Comment thread
appleboy marked this conversation as resolved.
# and restores the stripped '=' padding. A segment that isn't valid
# base64url-encoded JSON makes `fromjson` fail, which we surface as a clean
# error instead of a raw jq trace.
decode_jwt() {
local jwt="$1" hdr pld
local jwt="$1"
local -a parts
IFS='.' read -r -a parts <<<"$jwt"
[[ ${#parts[@]} -eq 3 && -n "${parts[0]}" && -n "${parts[1]}" && -n "${parts[2]}" ]] \
|| die "not a JWT (expected exactly three dot-separated segments)"
hdr=$(b64url_decode "${parts[0]}") || die "failed to decode JWT header"
pld=$(b64url_decode "${parts[1]}") || die "failed to decode JWT payload"
jq -n --argjson header "$hdr" --argjson payload "$pld" \
'{header: $header, payload: $payload}'
jq -rn --arg h "${parts[0]}" --arg p "${parts[1]}" '
def b64urld: gsub("-";"+") | gsub("_";"/")
| . + ("=" * ((4 - (length % 4)) % 4)) | @base64d;
{header: ($h | b64urld | fromjson), payload: ($p | b64urld | fromjson)}
' 2>/dev/null \
|| die "failed to decode JWT (a segment is not valid base64url-encoded JSON)"
}

load_dotenv() {
Expand Down Expand Up @@ -104,9 +101,6 @@ done

command -v curl >/dev/null || die "curl not found"
command -v jq >/dev/null || die "jq not found"
if [[ "$DECODE" == "1" ]]; then
command -v base64 >/dev/null || die "base64 not found (required for --decode)"
fi

: "${ISSUER_URL:?set ISSUER_URL (or add it to .env)}"
: "${CLIENT_ID:?set CLIENT_ID}"
Expand All @@ -117,10 +111,19 @@ curl_opts=(-sS --connect-timeout 10 --max-time 30)

discovery="${ISSUER_URL%/}/.well-known/openid-configuration"
meta=$(curl "${curl_opts[@]}" "$discovery") || die "discovery failed: $discovery"
# jq -er fails if the body isn't JSON OR if token_endpoint is absent, so a
# proxy HTML error page produces a clean message instead of a raw jq trace.
token_url=$(jq -er '.token_endpoint // empty' <<<"$meta" 2>/dev/null) \
|| die "discovery returned invalid JSON or missing token_endpoint: $discovery"
# Pull issuer + token_endpoint in one parse. jq fails if the body isn't JSON,
# so a proxy HTML error page produces a clean message instead of a raw trace.
# Fields are joined with ASCII RS (0x1e) so `read` preserves empty fields.
fields=$(jq -r '[.issuer // "", .token_endpoint // ""] | join("\u001e")' <<<"$meta" 2>/dev/null) \
|| die "discovery returned invalid JSON: $discovery"
IFS=$'\x1e' read -r disc_issuer token_url <<<"$fields"
[[ -n "$token_url" ]] || die "discovery response missing token_endpoint: $discovery"
# OIDC Discovery / RFC 8414 require the document's `issuer` to equal the
# issuer it was fetched for; the Go SDK's discovery client enforces this and
# so do we. It rejects a doc fetched for one issuer that claims to be another
# (issuer-confusion / mix-up) before we POST the client secret anywhere.
[[ "${disc_issuer%/}" == "${ISSUER_URL%/}" ]] \
|| die "discovery issuer mismatch: doc says ${disc_issuer:-<empty>}, expected ${ISSUER_URL%/}"
Comment thread
appleboy marked this conversation as resolved.

# Build the form body with each value URL-encoded separately.
# Secrets flow through jq's env (not argv) and curl's stdin (not argv) so
Expand Down
14 changes: 7 additions & 7 deletions go-m2m/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ Uses the **Client Credentials** grant. The service authenticates with its own `C

## Environment Variables

| Variable | Required | Description |
| --------------- | -------- | ------------------------------ |
| `AUTHGATE_URL` | Yes | AuthGate server URL |
| `CLIENT_ID` | Yes | OAuth 2.0 client identifier |
| `CLIENT_SECRET` | Yes | OAuth 2.0 client secret |
| Variable | Required | Description |
| --------------- | -------- | --------------------------- |
| `AUTHGATE_URL` | Yes | AuthGate server URL |
| `CLIENT_ID` | Yes | OAuth 2.0 client identifier |
| `CLIENT_SECRET` | Yes | OAuth 2.0 client secret |

## Usage

Expand Down Expand Up @@ -50,14 +50,14 @@ Environment variables take precedence over `.env` values. The `.env` file is opt
2. Creates an OAuth client with the client secret
3. Creates an auto-refreshing `TokenSource` with `profile` and `email` scopes and a 30-second expiry delta (refreshes token 30 seconds before it expires)
4. Obtains a pre-authenticated `http.Client` from the token source
5. Makes an authenticated GET request to `/oauth/userinfo`
5. Makes an authenticated GET request to the `userinfo_endpoint` advertised in discovery
6. Prints the response status and body (limited to 1 MB)

The token source automatically handles token acquisition and renewal — no manual refresh logic needed.

## Example Output

```
```txt
Status: 200
Body: {"sub":"service-uuid","client_id":"your-client-id",...}
```
Expand Down
13 changes: 10 additions & 3 deletions go-m2m/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ func main() {
}

// 2. Create OAuth client
client, err := oauth.NewClient(clientID, meta.Endpoints(),
endpoints := meta.Endpoints()
client, err := oauth.NewClient(clientID, endpoints,
oauth.WithClientSecret(clientSecret),
)
if err != nil {
Expand All @@ -63,9 +64,15 @@ func main() {
clientcreds.WithExpiryDelta(30*time.Second),
)

// 4. Use the auto-authenticated HTTP client
// 4. Use the auto-authenticated HTTP client against the userinfo endpoint
// the issuer advertised in discovery. Don't hardcode the path: that breaks
// on a trailing slash in AUTHGATE_URL (https://host//oauth/userinfo) and
// on any issuer whose userinfo endpoint isn't at <base>/oauth/userinfo.
if endpoints.UserinfoURL == "" {
log.Fatal("AuthGate discovery did not advertise a userinfo_endpoint")
}
httpClient := ts.HTTPClient()
resp, err := httpClient.Get(authgateURL + "/oauth/userinfo")
resp, err := httpClient.Get(endpoints.UserinfoURL)
Comment thread
appleboy marked this conversation as resolved.
if err != nil {
log.Fatal(err)
}
Expand Down
Loading