@chaindaddy/js 0.0.0-stage → 0.1.0
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/dist/browser.d.ts +1 -0
- package/dist/button.d.ts +2 -0
- package/dist/cd.js +10 -0
- package/dist/cd.js.sri +1 -0
- package/dist/core.d.ts +17 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +223 -0
- package/dist/signin.d.ts +57 -0
- package/dist/signin.js +168 -0
- package/package.json +50 -4
- package/README.md +0 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/button.d.ts
ADDED
package/dist/cd.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";(()=>{var m="0.1.0";function f(e){let t="";for(let r of e)t+=String.fromCharCode(r);return btoa(t).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function u(){return f(crypto.getRandomValues(new Uint8Array(32)))}function b(e,t,r,n){let i=s=>{if(s&&typeof s=="object"&&s.type===e)try{r(s)}catch{}},c=s=>{s.origin===t&&i(s.data)};window.addEventListener("message",c);let a=null;if(n&&typeof BroadcastChannel=="function"&&t===location.origin)try{a=new BroadcastChannel(n),a.onmessage=s=>i(s.data)}catch{a=null}return()=>{window.removeEventListener("message",c),a?.close()}}var d={get(e){try{return sessionStorage.getItem(e)}catch{return null}},set(e,t){try{sessionStorage.setItem(e,t)}catch{}},remove(e){try{sessionStorage.removeItem(e)}catch{}}};var x="https://api.chaindaddy.io",_={"https://api.chaindaddy.io":"https://chaindaddy.io/_oauth/authorize","https://staging-api.chaindaddy.io":"https://staging.chaindaddy.io/_oauth/authorize"},g="cd-signin",y="cd:signin",h="cd.signin";async function U(e){let t=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e));return f(new Uint8Array(t))}function k(e="openid"){let t=e.split(/\s+/).filter(Boolean);return(t.includes("openid")?t:["openid",...t]).join(" ")}async function C(e,t,r){let n=new URLSearchParams({response_type:"code",client_id:t.clientId,redirect_uri:t.redirectUri,scope:k(e.scope),state:t.state,nonce:t.nonce,code_challenge:await U(t.verifier),code_challenge_method:"S256"});return e.claims&&n.set("claims",typeof e.claims=="string"?e.claims:JSON.stringify(e.claims)),`${r}?${n}`}function S(e,t){return{code:t,codeVerifier:e.verifier,state:e.state,nonce:e.nonce,issuer:e.issuer,clientId:e.clientId,redirectUri:e.redirectUri}}function p(e){let t=(e.issuer??x).replace(/\/$/,""),r=e.authorizeUrl??_[t];if(!r)return Promise.reject(new Error("authorizeUrl is required for this issuer"));if(!e.clientId||!e.redirectUri)return Promise.reject(new Error("clientId and redirectUri are required"));let n={state:u(),nonce:u(),verifier:u(),issuer:t,clientId:e.clientId,redirectUri:e.redirectUri};d.set(h,JSON.stringify(n));let i=window.open("",g,"popup,width=480,height=720");return(async()=>{let c=await C(e,n,r);if(!i)return location.assign(c),null;i.location.href=c;let a=new URL(e.redirectUri,location.href).origin;return new Promise((s,l)=>{let w=b(y,a,o=>{o.state===n.state&&(w(),d.remove(h),typeof o.error=="string"&&o.error?l(new Error(o.error)):o.iss!==t?l(new Error("the answer came from another issuer")):typeof o.code!="string"||!o.code?l(new Error("the answer carried no code")):s(S(n,o.code)))},g);e.signal?.addEventListener("abort",()=>{w(),l(new Error("aborted"))})})})()}async function E(){let e=new URLSearchParams(location.search),t={type:y,code:e.get("code"),state:e.get("state"),iss:e.get("iss"),error:e.get("error")};if(window.name===g){try{window.opener?.postMessage(t,location.origin)}catch{}try{let i=new BroadcastChannel(g);i.postMessage(t),i.close()}catch{}return window.close(),null}let r=d.get(h);d.remove(h);let n=r?JSON.parse(r):null;if(!n||t.state!==n.state)throw new Error("the sign-in state does not match: start again");if(t.error)throw new Error(t.error);if(t.iss!==n.issuer)throw new Error("the answer came from another issuer");if(!t.code)throw new Error("the answer carried no code");return S(n,t.code)}async function v(e){let t=await fetch(`${e.issuer}/api/v2/oauth/token`,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"authorization_code",code:e.code,redirect_uri:e.redirectUri,client_id:e.clientId,code_verifier:e.codeVerifier})}),r=await t.json();if(!t.ok)throw new Error(`${r.error??t.status}: ${r.error_description??""}`.trim());return r}var R='<svg viewBox="0 0 24 24" fill="#FFD700" width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z" stroke="#B8860B" stroke-width="0.5"/><path d="M5 18h14v2H5v-2z" stroke="#B8860B" stroke-width="0.5"/></svg>',P=`
|
|
2
|
+
:host { display: inline-block; }
|
|
3
|
+
button { display: inline-flex; align-items: center; justify-content: center; gap: 10px; box-sizing: border-box;
|
|
4
|
+
min-height: 40px; padding: 0 16px; border-radius: 10px; font: 600 15px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
5
|
+
cursor: pointer; border: 1px solid #d0d5dd; background: #ffffff; color: #101828; }
|
|
6
|
+
button[data-theme="dark"] { background: #101828; color: #ffffff; border-color: #101828; }
|
|
7
|
+
button:focus-visible { outline: 2px solid #f59e0b; outline-offset: 2px; }
|
|
8
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
9
|
+
svg { flex: none; }
|
|
10
|
+
`;function I(){if(typeof customElements>"u"||customElements.get("cd-signin-button"))return;class e extends HTMLElement{constructor(){super(),this.root=this.attachShadow({mode:"open"})}connectedCallback(){this.render()}attributeChangedCallback(){this.isConnected&&this.render()}render(){let r=this.getAttribute("text")==="continue"?"Continue with Chain Daddy":"Sign in with Chain Daddy",n=this.getAttribute("theme")==="dark"?"dark":"light";this.root.innerHTML=`<style>${P}</style><button type="button" part="button" data-theme="${n}">${R}<span></span></button>`;let i=this.root.querySelector("button");i.querySelector("span").textContent=r,i.addEventListener("click",()=>this.start())}start(){let r=n=>this.getAttribute(n)||void 0;p({clientId:r("client-id")??"",redirectUri:r("redirect-uri")??"",scope:r("scope"),claims:r("claims"),issuer:r("issuer"),authorizeUrl:r("authorize-url")}).then(n=>n&&this.dispatchEvent(new CustomEvent("cd-signin",{detail:n,bubbles:!0,composed:!0})),n=>this.dispatchEvent(new CustomEvent("cd-signin-error",{detail:{message:String(n?.message??n)},bubbles:!0,composed:!0})))}}e.observedAttributes=["theme","text"],customElements.define("cd-signin-button",e)}var O={version:m,signIn:p,handleCallback:E,exchangeCode:v};window.ChainDaddy=Object.assign(window.ChainDaddy??{},O);I();})();
|
package/dist/cd.js.sri
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sha384-bZAt1vfljBunvk1nBTTcEr8Ir5WYMSnmrhuWN0uMusRcqf/oMlvNzVN26tBxHL+I
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
|
2
|
+
/** base64url without padding. */
|
|
3
|
+
export declare function b64url(bytes: Uint8Array): string;
|
|
4
|
+
/** 32 random bytes, base64url: 43 characters. */
|
|
5
|
+
export declare function randomToken(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Listens for messages of `type` from exactly `origin`, on window and on a
|
|
8
|
+
* same-origin BroadcastChannel (which still works when a COOP header has
|
|
9
|
+
* severed window.opener). Returns a function that stops listening.
|
|
10
|
+
*/
|
|
11
|
+
export declare function listen(type: string, origin: string, onMessage: (data: Record<string, unknown>) => void, channel?: string): () => void;
|
|
12
|
+
/** sessionStorage that never throws (private windows, blocked storage). */
|
|
13
|
+
export declare const store: {
|
|
14
|
+
get(key: string): string | null;
|
|
15
|
+
set(key: string, value: string): void;
|
|
16
|
+
remove(key: string): void;
|
|
17
|
+
};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
var VERSION = true ? "0.1.0" : "0.0.0";
|
|
3
|
+
function b64url(bytes) {
|
|
4
|
+
let s = "";
|
|
5
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
6
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
7
|
+
}
|
|
8
|
+
function randomToken() {
|
|
9
|
+
return b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
10
|
+
}
|
|
11
|
+
function listen(type, origin, onMessage, channel) {
|
|
12
|
+
const accept = (data) => {
|
|
13
|
+
if (data && typeof data === "object" && data.type === type) {
|
|
14
|
+
try {
|
|
15
|
+
onMessage(data);
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const onWindow = (e) => {
|
|
21
|
+
if (e.origin === origin) accept(e.data);
|
|
22
|
+
};
|
|
23
|
+
window.addEventListener("message", onWindow);
|
|
24
|
+
let bc = null;
|
|
25
|
+
if (channel && typeof BroadcastChannel === "function" && origin === location.origin) {
|
|
26
|
+
try {
|
|
27
|
+
bc = new BroadcastChannel(channel);
|
|
28
|
+
bc.onmessage = (e) => accept(e.data);
|
|
29
|
+
} catch {
|
|
30
|
+
bc = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return () => {
|
|
34
|
+
window.removeEventListener("message", onWindow);
|
|
35
|
+
bc?.close();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
var store = {
|
|
39
|
+
get(key) {
|
|
40
|
+
try {
|
|
41
|
+
return sessionStorage.getItem(key);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
set(key, value) {
|
|
47
|
+
try {
|
|
48
|
+
sessionStorage.setItem(key, value);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
remove(key) {
|
|
53
|
+
try {
|
|
54
|
+
sessionStorage.removeItem(key);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/signin.ts
|
|
61
|
+
var DEFAULT_ISSUER = "https://api.chaindaddy.io";
|
|
62
|
+
var AUTHORIZE = {
|
|
63
|
+
"https://api.chaindaddy.io": "https://chaindaddy.io/_oauth/authorize",
|
|
64
|
+
"https://staging-api.chaindaddy.io": "https://staging.chaindaddy.io/_oauth/authorize"
|
|
65
|
+
};
|
|
66
|
+
var POPUP_NAME = "cd-signin";
|
|
67
|
+
var MESSAGE = "cd:signin";
|
|
68
|
+
var STORAGE_KEY = "cd.signin";
|
|
69
|
+
async function challengeFor(verifier) {
|
|
70
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
71
|
+
return b64url(new Uint8Array(digest));
|
|
72
|
+
}
|
|
73
|
+
function withOpenID(scope = "openid") {
|
|
74
|
+
const parts = scope.split(/\s+/).filter(Boolean);
|
|
75
|
+
return (parts.includes("openid") ? parts : ["openid", ...parts]).join(" ");
|
|
76
|
+
}
|
|
77
|
+
async function authorizeURL(o, p, authorize) {
|
|
78
|
+
const q = new URLSearchParams({
|
|
79
|
+
response_type: "code",
|
|
80
|
+
client_id: p.clientId,
|
|
81
|
+
redirect_uri: p.redirectUri,
|
|
82
|
+
scope: withOpenID(o.scope),
|
|
83
|
+
state: p.state,
|
|
84
|
+
nonce: p.nonce,
|
|
85
|
+
code_challenge: await challengeFor(p.verifier),
|
|
86
|
+
code_challenge_method: "S256"
|
|
87
|
+
});
|
|
88
|
+
if (o.claims) q.set("claims", typeof o.claims === "string" ? o.claims : JSON.stringify(o.claims));
|
|
89
|
+
return `${authorize}?${q}`;
|
|
90
|
+
}
|
|
91
|
+
function result(p, code) {
|
|
92
|
+
return { code, codeVerifier: p.verifier, state: p.state, nonce: p.nonce, issuer: p.issuer, clientId: p.clientId, redirectUri: p.redirectUri };
|
|
93
|
+
}
|
|
94
|
+
function signIn(o) {
|
|
95
|
+
const issuer = (o.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
96
|
+
const authorize = o.authorizeUrl ?? AUTHORIZE[issuer];
|
|
97
|
+
if (!authorize) return Promise.reject(new Error("authorizeUrl is required for this issuer"));
|
|
98
|
+
if (!o.clientId || !o.redirectUri) return Promise.reject(new Error("clientId and redirectUri are required"));
|
|
99
|
+
const p = { state: randomToken(), nonce: randomToken(), verifier: randomToken(), issuer, clientId: o.clientId, redirectUri: o.redirectUri };
|
|
100
|
+
store.set(STORAGE_KEY, JSON.stringify(p));
|
|
101
|
+
const popup = window.open("", POPUP_NAME, "popup,width=480,height=720");
|
|
102
|
+
return (async () => {
|
|
103
|
+
const url = await authorizeURL(o, p, authorize);
|
|
104
|
+
if (!popup) {
|
|
105
|
+
location.assign(url);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
popup.location.href = url;
|
|
109
|
+
const callbackOrigin = new URL(o.redirectUri, location.href).origin;
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const stop = listen(MESSAGE, callbackOrigin, (m) => {
|
|
112
|
+
if (m.state !== p.state) return;
|
|
113
|
+
stop();
|
|
114
|
+
store.remove(STORAGE_KEY);
|
|
115
|
+
if (typeof m.error === "string" && m.error) reject(new Error(m.error));
|
|
116
|
+
else if (m.iss !== issuer) reject(new Error("the answer came from another issuer"));
|
|
117
|
+
else if (typeof m.code !== "string" || !m.code) reject(new Error("the answer carried no code"));
|
|
118
|
+
else resolve(result(p, m.code));
|
|
119
|
+
}, POPUP_NAME);
|
|
120
|
+
o.signal?.addEventListener("abort", () => {
|
|
121
|
+
stop();
|
|
122
|
+
reject(new Error("aborted"));
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
})();
|
|
126
|
+
}
|
|
127
|
+
async function handleCallback() {
|
|
128
|
+
const q = new URLSearchParams(location.search);
|
|
129
|
+
const message = { type: MESSAGE, code: q.get("code"), state: q.get("state"), iss: q.get("iss"), error: q.get("error") };
|
|
130
|
+
if (window.name === POPUP_NAME) {
|
|
131
|
+
try {
|
|
132
|
+
window.opener?.postMessage(message, location.origin);
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const bc = new BroadcastChannel(POPUP_NAME);
|
|
137
|
+
bc.postMessage(message);
|
|
138
|
+
bc.close();
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
window.close();
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const raw = store.get(STORAGE_KEY);
|
|
145
|
+
store.remove(STORAGE_KEY);
|
|
146
|
+
const p = raw ? JSON.parse(raw) : null;
|
|
147
|
+
if (!p || message.state !== p.state) throw new Error("the sign-in state does not match: start again");
|
|
148
|
+
if (message.error) throw new Error(message.error);
|
|
149
|
+
if (message.iss !== p.issuer) throw new Error("the answer came from another issuer");
|
|
150
|
+
if (!message.code) throw new Error("the answer carried no code");
|
|
151
|
+
return result(p, message.code);
|
|
152
|
+
}
|
|
153
|
+
async function exchangeCode(r) {
|
|
154
|
+
const res = await fetch(`${r.issuer}/api/v2/oauth/token`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
157
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: r.code, redirect_uri: r.redirectUri, client_id: r.clientId, code_verifier: r.codeVerifier })
|
|
158
|
+
});
|
|
159
|
+
const body = await res.json();
|
|
160
|
+
if (!res.ok) throw new Error(`${body.error ?? res.status}: ${body.error_description ?? ""}`.trim());
|
|
161
|
+
return body;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/button.ts
|
|
165
|
+
var CROWN = `<svg viewBox="0 0 24 24" fill="#FFD700" width="20" height="20" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5z" stroke="#B8860B" stroke-width="0.5"/><path d="M5 18h14v2H5v-2z" stroke="#B8860B" stroke-width="0.5"/></svg>`;
|
|
166
|
+
var STYLE = `
|
|
167
|
+
:host { display: inline-block; }
|
|
168
|
+
button { display: inline-flex; align-items: center; justify-content: center; gap: 10px; box-sizing: border-box;
|
|
169
|
+
min-height: 40px; padding: 0 16px; border-radius: 10px; font: 600 15px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
170
|
+
cursor: pointer; border: 1px solid #d0d5dd; background: #ffffff; color: #101828; }
|
|
171
|
+
button[data-theme="dark"] { background: #101828; color: #ffffff; border-color: #101828; }
|
|
172
|
+
button:focus-visible { outline: 2px solid #f59e0b; outline-offset: 2px; }
|
|
173
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
174
|
+
svg { flex: none; }
|
|
175
|
+
`;
|
|
176
|
+
function defineSignInButton() {
|
|
177
|
+
if (typeof customElements === "undefined" || customElements.get("cd-signin-button")) return;
|
|
178
|
+
class CdSigninButton extends HTMLElement {
|
|
179
|
+
constructor() {
|
|
180
|
+
super();
|
|
181
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
182
|
+
}
|
|
183
|
+
connectedCallback() {
|
|
184
|
+
this.render();
|
|
185
|
+
}
|
|
186
|
+
attributeChangedCallback() {
|
|
187
|
+
if (this.isConnected) this.render();
|
|
188
|
+
}
|
|
189
|
+
render() {
|
|
190
|
+
const label = this.getAttribute("text") === "continue" ? "Continue with Chain Daddy" : "Sign in with Chain Daddy";
|
|
191
|
+
const theme = this.getAttribute("theme") === "dark" ? "dark" : "light";
|
|
192
|
+
this.root.innerHTML = `<style>${STYLE}</style><button type="button" part="button" data-theme="${theme}">${CROWN}<span></span></button>`;
|
|
193
|
+
const button = this.root.querySelector("button");
|
|
194
|
+
button.querySelector("span").textContent = label;
|
|
195
|
+
button.addEventListener("click", () => this.start());
|
|
196
|
+
}
|
|
197
|
+
start() {
|
|
198
|
+
const attr = (n) => this.getAttribute(n) || void 0;
|
|
199
|
+
signIn({
|
|
200
|
+
clientId: attr("client-id") ?? "",
|
|
201
|
+
redirectUri: attr("redirect-uri") ?? "",
|
|
202
|
+
scope: attr("scope"),
|
|
203
|
+
claims: attr("claims"),
|
|
204
|
+
issuer: attr("issuer"),
|
|
205
|
+
authorizeUrl: attr("authorize-url")
|
|
206
|
+
}).then(
|
|
207
|
+
(r) => r && this.dispatchEvent(new CustomEvent("cd-signin", { detail: r, bubbles: true, composed: true })),
|
|
208
|
+
(e) => this.dispatchEvent(new CustomEvent("cd-signin-error", { detail: { message: String(e?.message ?? e) }, bubbles: true, composed: true }))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
CdSigninButton.observedAttributes = ["theme", "text"];
|
|
213
|
+
customElements.define("cd-signin-button", CdSigninButton);
|
|
214
|
+
}
|
|
215
|
+
export {
|
|
216
|
+
DEFAULT_ISSUER,
|
|
217
|
+
VERSION,
|
|
218
|
+
challengeFor,
|
|
219
|
+
defineSignInButton,
|
|
220
|
+
exchangeCode,
|
|
221
|
+
handleCallback,
|
|
222
|
+
signIn
|
|
223
|
+
};
|
package/dist/signin.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const DEFAULT_ISSUER = "https://api.chaindaddy.io";
|
|
2
|
+
export interface SignInOptions {
|
|
3
|
+
/** Your client id: the URL of your client.json, or a registered cdc_ id. */
|
|
4
|
+
clientId: string;
|
|
5
|
+
/** Your callback page, exactly as your client lists it. It must be on this page's origin. */
|
|
6
|
+
redirectUri: string;
|
|
7
|
+
/** Space-separated scopes; openid is added when missing. Default "openid". */
|
|
8
|
+
scope?: string;
|
|
9
|
+
/** The OIDC `claims` request (holdings of your coin, crowns): an object or its JSON. */
|
|
10
|
+
claims?: object | string;
|
|
11
|
+
/** Default https://api.chaindaddy.io. */
|
|
12
|
+
issuer?: string;
|
|
13
|
+
/** The consent page; derived from the issuer for Chain Daddy's own. */
|
|
14
|
+
authorizeUrl?: string;
|
|
15
|
+
/** Stops waiting. Closing the popup does not: a closed-looking popup is not a cancel. */
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
/** What your server exchanges at the token endpoint (or exchangeCode does here). */
|
|
19
|
+
export interface SignInResult {
|
|
20
|
+
code: string;
|
|
21
|
+
codeVerifier: string;
|
|
22
|
+
state: string;
|
|
23
|
+
nonce: string;
|
|
24
|
+
issuer: string;
|
|
25
|
+
clientId: string;
|
|
26
|
+
redirectUri: string;
|
|
27
|
+
}
|
|
28
|
+
/** RFC 7636 S256: base64url(SHA-256(verifier)). */
|
|
29
|
+
export declare function challengeFor(verifier: string): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Starts a sign-in. Call it from a click handler: the popup opens at once,
|
|
32
|
+
* before anything is awaited, or a browser blocks it. Resolves with the code
|
|
33
|
+
* once the visitor approves; resolves null when the popup was blocked and the
|
|
34
|
+
* page is redirecting instead (your callback page then calls handleCallback).
|
|
35
|
+
* Rejects when they cancel (access_denied) or the answer is not for this
|
|
36
|
+
* request.
|
|
37
|
+
*/
|
|
38
|
+
export declare function signIn(o: SignInOptions): Promise<SignInResult | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Runs on your callback page. In the sign-in popup it hands the answer to the
|
|
41
|
+
* page that opened it (by postMessage to this origin, and by a same-origin
|
|
42
|
+
* BroadcastChannel in case the opener link was cut), closes, and resolves
|
|
43
|
+
* null. After a full-page redirect it checks the answer against the request
|
|
44
|
+
* this tab stored and resolves with the code, or rejects.
|
|
45
|
+
*/
|
|
46
|
+
export declare function handleCallback(): Promise<SignInResult | null>;
|
|
47
|
+
/**
|
|
48
|
+
* Redeems the code at the token endpoint from the browser (a public client:
|
|
49
|
+
* no secret). Send the id_token to your server and verify it there; do not
|
|
50
|
+
* trust it here.
|
|
51
|
+
*/
|
|
52
|
+
export declare function exchangeCode(r: SignInResult): Promise<{
|
|
53
|
+
id_token: string;
|
|
54
|
+
access_token: string;
|
|
55
|
+
expires_in: number;
|
|
56
|
+
scope: string;
|
|
57
|
+
}>;
|
package/dist/signin.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
function b64url(bytes) {
|
|
3
|
+
let s = "";
|
|
4
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
5
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function randomToken() {
|
|
8
|
+
return b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
9
|
+
}
|
|
10
|
+
function listen(type, origin, onMessage, channel) {
|
|
11
|
+
const accept = (data) => {
|
|
12
|
+
if (data && typeof data === "object" && data.type === type) {
|
|
13
|
+
try {
|
|
14
|
+
onMessage(data);
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const onWindow = (e) => {
|
|
20
|
+
if (e.origin === origin) accept(e.data);
|
|
21
|
+
};
|
|
22
|
+
window.addEventListener("message", onWindow);
|
|
23
|
+
let bc = null;
|
|
24
|
+
if (channel && typeof BroadcastChannel === "function" && origin === location.origin) {
|
|
25
|
+
try {
|
|
26
|
+
bc = new BroadcastChannel(channel);
|
|
27
|
+
bc.onmessage = (e) => accept(e.data);
|
|
28
|
+
} catch {
|
|
29
|
+
bc = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return () => {
|
|
33
|
+
window.removeEventListener("message", onWindow);
|
|
34
|
+
bc?.close();
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
var store = {
|
|
38
|
+
get(key) {
|
|
39
|
+
try {
|
|
40
|
+
return sessionStorage.getItem(key);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
set(key, value) {
|
|
46
|
+
try {
|
|
47
|
+
sessionStorage.setItem(key, value);
|
|
48
|
+
} catch {
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
remove(key) {
|
|
52
|
+
try {
|
|
53
|
+
sessionStorage.removeItem(key);
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// src/signin.ts
|
|
60
|
+
var DEFAULT_ISSUER = "https://api.chaindaddy.io";
|
|
61
|
+
var AUTHORIZE = {
|
|
62
|
+
"https://api.chaindaddy.io": "https://chaindaddy.io/_oauth/authorize",
|
|
63
|
+
"https://staging-api.chaindaddy.io": "https://staging.chaindaddy.io/_oauth/authorize"
|
|
64
|
+
};
|
|
65
|
+
var POPUP_NAME = "cd-signin";
|
|
66
|
+
var MESSAGE = "cd:signin";
|
|
67
|
+
var STORAGE_KEY = "cd.signin";
|
|
68
|
+
async function challengeFor(verifier) {
|
|
69
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
70
|
+
return b64url(new Uint8Array(digest));
|
|
71
|
+
}
|
|
72
|
+
function withOpenID(scope = "openid") {
|
|
73
|
+
const parts = scope.split(/\s+/).filter(Boolean);
|
|
74
|
+
return (parts.includes("openid") ? parts : ["openid", ...parts]).join(" ");
|
|
75
|
+
}
|
|
76
|
+
async function authorizeURL(o, p, authorize) {
|
|
77
|
+
const q = new URLSearchParams({
|
|
78
|
+
response_type: "code",
|
|
79
|
+
client_id: p.clientId,
|
|
80
|
+
redirect_uri: p.redirectUri,
|
|
81
|
+
scope: withOpenID(o.scope),
|
|
82
|
+
state: p.state,
|
|
83
|
+
nonce: p.nonce,
|
|
84
|
+
code_challenge: await challengeFor(p.verifier),
|
|
85
|
+
code_challenge_method: "S256"
|
|
86
|
+
});
|
|
87
|
+
if (o.claims) q.set("claims", typeof o.claims === "string" ? o.claims : JSON.stringify(o.claims));
|
|
88
|
+
return `${authorize}?${q}`;
|
|
89
|
+
}
|
|
90
|
+
function result(p, code) {
|
|
91
|
+
return { code, codeVerifier: p.verifier, state: p.state, nonce: p.nonce, issuer: p.issuer, clientId: p.clientId, redirectUri: p.redirectUri };
|
|
92
|
+
}
|
|
93
|
+
function signIn(o) {
|
|
94
|
+
const issuer = (o.issuer ?? DEFAULT_ISSUER).replace(/\/$/, "");
|
|
95
|
+
const authorize = o.authorizeUrl ?? AUTHORIZE[issuer];
|
|
96
|
+
if (!authorize) return Promise.reject(new Error("authorizeUrl is required for this issuer"));
|
|
97
|
+
if (!o.clientId || !o.redirectUri) return Promise.reject(new Error("clientId and redirectUri are required"));
|
|
98
|
+
const p = { state: randomToken(), nonce: randomToken(), verifier: randomToken(), issuer, clientId: o.clientId, redirectUri: o.redirectUri };
|
|
99
|
+
store.set(STORAGE_KEY, JSON.stringify(p));
|
|
100
|
+
const popup = window.open("", POPUP_NAME, "popup,width=480,height=720");
|
|
101
|
+
return (async () => {
|
|
102
|
+
const url = await authorizeURL(o, p, authorize);
|
|
103
|
+
if (!popup) {
|
|
104
|
+
location.assign(url);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
popup.location.href = url;
|
|
108
|
+
const callbackOrigin = new URL(o.redirectUri, location.href).origin;
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const stop = listen(MESSAGE, callbackOrigin, (m) => {
|
|
111
|
+
if (m.state !== p.state) return;
|
|
112
|
+
stop();
|
|
113
|
+
store.remove(STORAGE_KEY);
|
|
114
|
+
if (typeof m.error === "string" && m.error) reject(new Error(m.error));
|
|
115
|
+
else if (m.iss !== issuer) reject(new Error("the answer came from another issuer"));
|
|
116
|
+
else if (typeof m.code !== "string" || !m.code) reject(new Error("the answer carried no code"));
|
|
117
|
+
else resolve(result(p, m.code));
|
|
118
|
+
}, POPUP_NAME);
|
|
119
|
+
o.signal?.addEventListener("abort", () => {
|
|
120
|
+
stop();
|
|
121
|
+
reject(new Error("aborted"));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
})();
|
|
125
|
+
}
|
|
126
|
+
async function handleCallback() {
|
|
127
|
+
const q = new URLSearchParams(location.search);
|
|
128
|
+
const message = { type: MESSAGE, code: q.get("code"), state: q.get("state"), iss: q.get("iss"), error: q.get("error") };
|
|
129
|
+
if (window.name === POPUP_NAME) {
|
|
130
|
+
try {
|
|
131
|
+
window.opener?.postMessage(message, location.origin);
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const bc = new BroadcastChannel(POPUP_NAME);
|
|
136
|
+
bc.postMessage(message);
|
|
137
|
+
bc.close();
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
window.close();
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const raw = store.get(STORAGE_KEY);
|
|
144
|
+
store.remove(STORAGE_KEY);
|
|
145
|
+
const p = raw ? JSON.parse(raw) : null;
|
|
146
|
+
if (!p || message.state !== p.state) throw new Error("the sign-in state does not match: start again");
|
|
147
|
+
if (message.error) throw new Error(message.error);
|
|
148
|
+
if (message.iss !== p.issuer) throw new Error("the answer came from another issuer");
|
|
149
|
+
if (!message.code) throw new Error("the answer carried no code");
|
|
150
|
+
return result(p, message.code);
|
|
151
|
+
}
|
|
152
|
+
async function exchangeCode(r) {
|
|
153
|
+
const res = await fetch(`${r.issuer}/api/v2/oauth/token`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
156
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code: r.code, redirect_uri: r.redirectUri, client_id: r.clientId, code_verifier: r.codeVerifier })
|
|
157
|
+
});
|
|
158
|
+
const body = await res.json();
|
|
159
|
+
if (!res.ok) throw new Error(`${body.error ?? res.status}: ${body.error_description ?? ""}`.trim());
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
export {
|
|
163
|
+
DEFAULT_ISSUER,
|
|
164
|
+
challengeFor,
|
|
165
|
+
exchangeCode,
|
|
166
|
+
handleCallback,
|
|
167
|
+
signIn
|
|
168
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chaindaddy/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Chain Daddy for any website: Sign in with Chain Daddy (a popup and a button), with embeds and checkout to follow",
|
|
5
|
+
"homepage": "https://docs.chaindaddy.io/developer/javascript-sdk",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://gitlab.com/opencrown/chaindaddy-js.git",
|
|
9
|
+
"directory": "js"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"tag": "beta"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./signin": {
|
|
22
|
+
"types": "./dist/signin.d.ts",
|
|
23
|
+
"default": "./dist/signin.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node scripts/build.mjs",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"chaindaddy",
|
|
37
|
+
"oidc",
|
|
38
|
+
"sign-in",
|
|
39
|
+
"crypto",
|
|
40
|
+
"wallet"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"esbuild": "^0.25.0",
|
|
45
|
+
"jsdom": "^26.0.0",
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"vitest": "^3.2.0"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/README.md
DELETED