@broberg/sso 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/dist/index.cjs +144 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +47 -1
- package/dist/index.d.ts +47 -1
- package/dist/index.js +139 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -552,3 +552,21 @@ try {
|
|
|
552
552
|
- **Never accepted as a ticket:** an ID token or a logout token, even though BID signed both.
|
|
553
553
|
- **BID briefly down:** a key already in the cache still verifies, with no network call. An unknown key while BID is down throws `JwksUnavailableError` — answer 503, do not reject the caller.
|
|
554
554
|
- Not a login client: a service that only receives tickets needs no `client_id`, no redirect and no session.
|
|
555
|
+
|
|
556
|
+
## Getting a ticket — `fetchTicket` (since 0.14.1 — 0.14.0 was tagged but never reached npm)
|
|
557
|
+
|
|
558
|
+
The sending half: a workload on **Fly** or in **GitHub Actions** gets a Broberg ID ticket with no key at all. It proves who it is with the identity its platform already gives it, and BID exchanges that (RFC 8693). Nothing secret is stored anywhere.
|
|
559
|
+
|
|
560
|
+
```ts
|
|
561
|
+
import { fetchTicket, NoWorkloadIdentityError, TicketExchangeError, TicketUnavailableError } from "@broberg/sso";
|
|
562
|
+
|
|
563
|
+
const ticket = await fetchTicket({ audience: "discovery", scope: "discovery:read" });
|
|
564
|
+
await fetch("https://discovery.broberg.ai/api/fleet", { headers: { authorization: `Bearer ${ticket}` } });
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- **Fly:** asks the machine API (`unix:/.fly/api`, `POST /v1/tokens/oidc`, `aud` = the issuer). The ticket's subject is `svc:<fly app name>` — BID derives it from the app name, so the rule in BID must name the app exactly.
|
|
568
|
+
- **GitHub Actions:** uses `ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`; the workflow needs `permissions: id-token: write`. Subject `svc:gh:<owner>/<repo>`, and BID's rule names ONE workflow file (a new file cannot get a ticket).
|
|
569
|
+
- **Anywhere else** (a Mac session, a laptop): `NoWorkloadIdentityError` — keep using the session key there.
|
|
570
|
+
- **Cached** per audience + scope until 30 s before exp (BID issues 5 min), and concurrent calls share one exchange. `createTicketClient({...})` gives you your own cache and injectable platform/fetch for tests.
|
|
571
|
+
- **Errors:** `TicketExchangeError` (BID refused — `code` is BID's name for it, `status` the HTTP status; permanent, fix the rule) · `TicketUnavailableError` (BID or the platform unreachable / 5xx; transient) · `NoWorkloadIdentityError`. BID's codes include `no_rule_for_audience`, `scope_not_allowed`, `service_not_registered`, `audience_unknown`, `workflow_not_allowed`, `fly_org_untrusted`, `fly_audience_mismatch`, `github_owner_untrusted`, `client_mismatch`, `issuer_unknown`.
|
|
572
|
+
- The ticket is never logged or put in an error message.
|
package/dist/index.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var jose = require('jose');
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var http = require('http');
|
|
4
6
|
|
|
5
7
|
// src/config.ts
|
|
6
8
|
var SsoConfigError = class extends Error {
|
|
@@ -666,6 +668,142 @@ function createSsoClient(config, options = {}) {
|
|
|
666
668
|
}
|
|
667
669
|
};
|
|
668
670
|
}
|
|
671
|
+
var DEFAULT_ISSUER = "https://id.broberg.ai";
|
|
672
|
+
var FLY_SOCKET = "/.fly/api";
|
|
673
|
+
var NoWorkloadIdentityError = class extends Error {
|
|
674
|
+
constructor(message) {
|
|
675
|
+
super(message);
|
|
676
|
+
this.name = "NoWorkloadIdentityError";
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
var TicketExchangeError = class extends Error {
|
|
680
|
+
constructor(message, code, status) {
|
|
681
|
+
super(message);
|
|
682
|
+
this.code = code;
|
|
683
|
+
this.status = status;
|
|
684
|
+
this.name = "TicketExchangeError";
|
|
685
|
+
}
|
|
686
|
+
code;
|
|
687
|
+
status;
|
|
688
|
+
};
|
|
689
|
+
var TicketUnavailableError = class extends Error {
|
|
690
|
+
constructor(message) {
|
|
691
|
+
super(message);
|
|
692
|
+
this.name = "TicketUnavailableError";
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
function flyOidcOverSocket(aud) {
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
const body = JSON.stringify({ aud });
|
|
698
|
+
const req = http.request(
|
|
699
|
+
{ socketPath: FLY_SOCKET, path: "/v1/tokens/oidc", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) } },
|
|
700
|
+
(res) => {
|
|
701
|
+
let data = "";
|
|
702
|
+
res.setEncoding("utf8");
|
|
703
|
+
res.on("data", (c) => data += c);
|
|
704
|
+
res.on("end", () => {
|
|
705
|
+
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(data.trim());
|
|
706
|
+
else reject(new TicketUnavailableError(`Fly OIDC answered ${res.statusCode}`));
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
);
|
|
710
|
+
req.on("error", (e) => reject(new TicketUnavailableError(`Fly OIDC socket: ${e.message}`)));
|
|
711
|
+
req.end(body);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function jwtExp(token) {
|
|
715
|
+
try {
|
|
716
|
+
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
717
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
718
|
+
} catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function createTicketClient(options = {}) {
|
|
723
|
+
const issuer = (options.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
|
|
724
|
+
const renewBeforeSec = options.renewBeforeSec ?? 30;
|
|
725
|
+
const env = options.env ?? process.env;
|
|
726
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
727
|
+
const now = options.now ?? (() => Date.now());
|
|
728
|
+
const flyOidc = options.flyOidc ?? flyOidcOverSocket;
|
|
729
|
+
const onFly = options.onFly ?? (() => !!env.FLY_APP_NAME && fs.existsSync(FLY_SOCKET));
|
|
730
|
+
const cache = /* @__PURE__ */ new Map();
|
|
731
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
732
|
+
async function platformToken() {
|
|
733
|
+
if (onFly()) return flyOidc(issuer);
|
|
734
|
+
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
735
|
+
const bearer = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
736
|
+
if (url && bearer) {
|
|
737
|
+
let res;
|
|
738
|
+
try {
|
|
739
|
+
res = await fetchImpl(`${url}&audience=${encodeURIComponent(issuer)}`, { headers: { authorization: `Bearer ${bearer}` } });
|
|
740
|
+
} catch (e) {
|
|
741
|
+
throw new TicketUnavailableError(`GitHub OIDC endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
742
|
+
}
|
|
743
|
+
if (!res.ok) throw new TicketUnavailableError(`GitHub OIDC endpoint answered ${res.status}`);
|
|
744
|
+
const { value } = await res.json();
|
|
745
|
+
if (!value) throw new TicketUnavailableError("GitHub OIDC endpoint returned no token");
|
|
746
|
+
return value;
|
|
747
|
+
}
|
|
748
|
+
throw new NoWorkloadIdentityError(
|
|
749
|
+
"No workload identity to exchange for a ticket: not on a Fly machine (FLY_APP_NAME + /.fly/api) and not in a GitHub Actions job (ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN \u2014 add `permissions: id-token: write`). A Mac session keeps using its session key."
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
async function exchange(audience, scope) {
|
|
753
|
+
const subject = await platformToken();
|
|
754
|
+
const form = new URLSearchParams({
|
|
755
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
756
|
+
subject_token: subject,
|
|
757
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
|
|
758
|
+
audience
|
|
759
|
+
});
|
|
760
|
+
if (scope) form.set("scope", scope);
|
|
761
|
+
let res;
|
|
762
|
+
try {
|
|
763
|
+
res = await fetchImpl(`${issuer}/oauth2/token`, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
766
|
+
body: form
|
|
767
|
+
});
|
|
768
|
+
} catch (e) {
|
|
769
|
+
throw new TicketUnavailableError(`${issuer} unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
770
|
+
}
|
|
771
|
+
let body = {};
|
|
772
|
+
try {
|
|
773
|
+
body = await res.json();
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
if (res.status >= 500) throw new TicketUnavailableError(`${issuer} answered ${res.status}`);
|
|
777
|
+
if (!res.ok || !body.access_token) {
|
|
778
|
+
const desc = body.error_description ?? "";
|
|
779
|
+
const code = desc.includes(":") ? desc.slice(0, desc.indexOf(":")).trim() : body.error ?? "exchange_refused";
|
|
780
|
+
throw new TicketExchangeError(`Broberg ID refused the ticket for ${audience}: ${desc || body.error || res.status}`, code, res.status);
|
|
781
|
+
}
|
|
782
|
+
const nowSec = Math.floor(now() / 1e3);
|
|
783
|
+
const exp = jwtExp(body.access_token) ?? nowSec + (body.expires_in ?? 300);
|
|
784
|
+
return { token: body.access_token, exp };
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
async get({ audience, scope }) {
|
|
788
|
+
const key = `${audience} ${scope ?? ""}`;
|
|
789
|
+
const hit = cache.get(key);
|
|
790
|
+
if (hit && hit.exp - renewBeforeSec > now() / 1e3) return hit.token;
|
|
791
|
+
const pending = inFlight.get(key);
|
|
792
|
+
if (pending) return pending;
|
|
793
|
+
const p = exchange(audience, scope).then((t) => {
|
|
794
|
+
cache.set(key, t);
|
|
795
|
+
return t.token;
|
|
796
|
+
}).finally(() => inFlight.delete(key));
|
|
797
|
+
inFlight.set(key, p);
|
|
798
|
+
return p;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
var shared = null;
|
|
803
|
+
function fetchTicket(opts) {
|
|
804
|
+
shared ??= createTicketClient();
|
|
805
|
+
return shared.get(opts);
|
|
806
|
+
}
|
|
669
807
|
var PRINCIPAL_TYPES = ["human", "service", "agent"];
|
|
670
808
|
function createTicketVerifier(options) {
|
|
671
809
|
const {
|
|
@@ -884,22 +1022,28 @@ function memoryTokenStore(options = {}) {
|
|
|
884
1022
|
}
|
|
885
1023
|
|
|
886
1024
|
exports.AVATAR_TYPES = AVATAR_TYPES;
|
|
1025
|
+
exports.DEFAULT_ISSUER = DEFAULT_ISSUER;
|
|
887
1026
|
exports.DEFAULT_SESSION_MAX_AGE = DEFAULT_SESSION_MAX_AGE;
|
|
888
1027
|
exports.JwksError = JwksError;
|
|
889
1028
|
exports.JwksUnavailableError = JwksUnavailableError;
|
|
890
1029
|
exports.JwksUnknownKeyError = JwksUnknownKeyError;
|
|
891
1030
|
exports.MAX_AVATAR_BYTES = MAX_AVATAR_BYTES;
|
|
892
1031
|
exports.MAX_INVITATIONS = MAX_INVITATIONS;
|
|
1032
|
+
exports.NoWorkloadIdentityError = NoWorkloadIdentityError;
|
|
893
1033
|
exports.SsoAppApiError = SsoAppApiError;
|
|
894
1034
|
exports.SsoAvatarRejectedError = SsoAvatarRejectedError;
|
|
895
1035
|
exports.SsoConfigError = SsoConfigError;
|
|
896
1036
|
exports.SsoError = SsoError;
|
|
897
1037
|
exports.SsoInsufficientScopeError = SsoInsufficientScopeError;
|
|
898
1038
|
exports.SsoReauthError = SsoReauthError;
|
|
1039
|
+
exports.TicketExchangeError = TicketExchangeError;
|
|
1040
|
+
exports.TicketUnavailableError = TicketUnavailableError;
|
|
899
1041
|
exports.cookieHeader = cookieHeader;
|
|
900
1042
|
exports.createJwksCache = createJwksCache;
|
|
901
1043
|
exports.createSsoClient = createSsoClient;
|
|
1044
|
+
exports.createTicketClient = createTicketClient;
|
|
902
1045
|
exports.createTicketVerifier = createTicketVerifier;
|
|
1046
|
+
exports.fetchTicket = fetchTicket;
|
|
903
1047
|
exports.loadSsoConfig = loadSsoConfig;
|
|
904
1048
|
exports.memoryTokenStore = memoryTokenStore;
|
|
905
1049
|
exports.readCookie = readCookie;
|