Skip to content

Commit 1c70e4c

Browse files
authored
Merge pull request #11 from softwarepub/3-add-oauth
Having OAuth
2 parents 1de7aee + 846b6dd commit 1c70e4c

8 files changed

Lines changed: 438 additions & 107 deletions

File tree

public/git-login/index.html

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<!DOCTYPE html>
2+
<html lang="en-US">
3+
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
8+
<link rel="stylesheet" href="/style.css">
9+
<link rel="icon" href="data:,">
10+
<link rel="icon" href="../pictures/Logo.png" type="image/icon type">
11+
12+
<script type="module" src="./main.js"></script>
13+
14+
<title>GitLab Account Setup | Software CaRD</title>
15+
</head>
16+
17+
<body>
18+
<h1><a href="../">Software CaRD</a></h1>
19+
<h3>GitLab Account Setup</h3>
20+
<div class="section" id="platform-section">
21+
<label for="platform-select">Choose a platform:</label>
22+
<select id="platform-select" aria-label="Choose OAuth platform">
23+
<option value="" disabled selected>— Select a platform —</option>
24+
<!-- Options are generated automatically -->
25+
</select>
26+
<p class="thin" id="platform-hint"></p>
27+
</div>
28+
<div id="auth-ui" class="hidden">
29+
<p>
30+
Please click <a id="token-link" href="https://codebase.helmholtz.cloud/-/user_settings/personal_access_tokens"
31+
target="_blank">here</a> to create a personal access token.
32+
Copy it, paste it in the box below, and click "Save".
33+
The token should start with <code>glpat-</code>.
34+
</p>
35+
<p>
36+
The token will be saved in the browser.
37+
Do not use this feature on a shared computer account.
38+
</p>
39+
<p id="already-known"></p>
40+
<p>
41+
<label for="token-input">Token:</label>
42+
<input type="password" id="token-input" name="api-token">
43+
<button id="token-save-button">Save</button>
44+
</p>
45+
<br>
46+
<div>
47+
Alternatively you can use OAuth: <button id="oauth-button">Connect to ((platform-label))</button>
48+
</div>
49+
<br>
50+
<a href="../"><button>Go back</button></a>
51+
</div>
52+
</body>
53+
54+
</html>

public/git-login/main.js

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import * as User from "/modules/user.js"
2+
3+
window.onload = async function () {
4+
// Setup site when user is already logged in
5+
const savedToken = User.getApiToken();
6+
if (savedToken) {
7+
document.getElementById("token-input").value = savedToken;
8+
const alreadyKnownText = document.getElementById("already-known");
9+
const name = User.getName();
10+
const username = User.getUsername();
11+
// TODO: This feels like a good use case for a web component...
12+
alreadyKnownText.innerHTML = `You are already authenticated as ${name} (<code>${username}</code>).`;
13+
}
14+
15+
// Token save button onclick
16+
var saveButton = document.getElementById("token-save-button");
17+
saveButton.onclick = async function () {
18+
var tokenInput = document.getElementById("token-input");
19+
const token = tokenInput.value.trim();
20+
if (token) {
21+
if (token === savedToken) {
22+
window.location = "../";
23+
return;
24+
}
25+
const platform_name = User.getGitPlatformName();
26+
const platform = User.getGitPlatform();
27+
if (!platform) {
28+
console.debug("No platform saved.");
29+
return;
30+
}
31+
const headers = { "Content-Type": "application/json" };
32+
if (token.startsWith("glpat")) {
33+
headers["PRIVATE-TOKEN"] = token;
34+
} else {
35+
headers["Authorization"] = `Bearer ${token}`;
36+
}
37+
38+
const response = await fetch(platform.apiUrl + "/user", { headers });
39+
40+
if (!response.ok) {
41+
alert("Could not authenticate");
42+
location.reload();
43+
return;
44+
}
45+
46+
const userData = await response.json();
47+
48+
if (platform.host == "gitlab") {
49+
localStorage.setItem("gitlab-username", userData["username"]);
50+
localStorage.setItem("gitlab-name", userData["name"]);
51+
localStorage.setItem("gitlab-api-token", token);
52+
User.setUser(platform_name, token, userData["username"], userData["name"]);
53+
} else {
54+
localStorage.setItem("gitlab-username", userData["login"]);
55+
localStorage.setItem("gitlab-name", userData["name"]);
56+
localStorage.setItem("gitlab-api-token", token);
57+
User.setUser(platform_name, token, userData["login"], userData["name"]);
58+
}
59+
60+
window.location = "../";
61+
return;
62+
}
63+
};
64+
65+
// Show the authorization UI and update button caption & token link
66+
async function onPlatformSelected(key) {
67+
const select = document.getElementById("platform-select");
68+
if (key && select.value != key) {
69+
select.value = key;
70+
}
71+
const platform = User.getGitPlatform(key);
72+
const authUI = document.getElementById("auth-ui");
73+
const oauthBtn = document.getElementById("oauth-button");
74+
const tokenA = document.getElementById("token-link");
75+
76+
// Hide everything if nothing valid is selected
77+
if (!platform) {
78+
authUI.classList.add("hidden");
79+
console.log("hidden");
80+
} else {
81+
console.log("show");
82+
User.setGitPlatform(key);
83+
// show ui
84+
authUI.classList.remove("hidden");
85+
// Update button caption
86+
oauthBtn.textContent = `Connect to ${platform.shortUrl}`;
87+
// Update token link
88+
if (platform.host == "github") { // github
89+
tokenA.href = `${platform.baseUrl}/settings/personal-access-tokens`
90+
// diable oauth for GitHub for now
91+
document.getElementById("oauth-button").disabled = true;
92+
} else { // gitlab
93+
tokenA.href = `${platform.baseUrl}/-/user_settings/personal_access_tokens`;
94+
document.getElementById("oauth-button").disabled = false;
95+
}
96+
}
97+
}
98+
99+
const REDIRECT_URI = location.origin + location.pathname;
100+
const SCOPE_GL = "read_api";
101+
const SCOPE_GH = "read:user user:email repo";
102+
103+
// PKCE
104+
const b64url = ab => btoa(String.fromCharCode(...new Uint8Array(ab)))
105+
.replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
106+
async function sha256(input) {
107+
const data = new TextEncoder().encode(input);
108+
return await crypto.subtle.digest("SHA-256", data);
109+
}
110+
function randUrlSafe(len=64){
111+
const b = crypto.getRandomValues(new Uint8Array(len));
112+
return b64url(b).slice(0, len);
113+
}
114+
115+
function buildUrl(base, path, params) {
116+
const u = new URL(path, base);
117+
u.search = params.toString();
118+
return u.href;
119+
}
120+
121+
// Start OAuth
122+
async function startLoginWithOAuth() {
123+
const platform = User.getGitPlatform();
124+
if (!platform) {return;}
125+
const base_url = platform.baseUrl;
126+
const client_id = platform.clientId;
127+
const host = platform.host;
128+
129+
if (!base_url || !client_id) throw new Error("baseUrl and clientId are required.");
130+
131+
if (host == "github") {
132+
const state = randUrlSafe(24);
133+
const code_verifier = randUrlSafe(96);
134+
const code_challenge = b64url(await sha256(code_verifier));
135+
136+
sessionStorage.setItem(`pkce_${state}`, JSON.stringify({
137+
code_verifier, client_id, base_url, REDIRECT_URI
138+
}));
139+
140+
const params = new URLSearchParams({
141+
client_id: client_id,
142+
redirect_uri: REDIRECT_URI,
143+
response_type: "code",
144+
scope: SCOPE_GH,
145+
state,
146+
code_challenge,
147+
code_challenge_method: "S256",
148+
});
149+
150+
const auth_url = new URL("/login/oauth/authorize", base_url);
151+
auth_url.search = params.toString();
152+
console.debug("Authorize URL:", auth_url);
153+
location.assign(auth_url.toString());
154+
155+
} else if (host == "gitlab") {
156+
const state = randUrlSafe(24);
157+
const code_verifier = randUrlSafe(96);
158+
const code_challenge = b64url(await sha256(code_verifier));
159+
160+
sessionStorage.setItem(`pkce_${state}`, JSON.stringify({
161+
code_verifier, client_id, base_url, REDIRECT_URI
162+
}));
163+
164+
const params = new URLSearchParams({
165+
client_id: client_id,
166+
redirect_uri: REDIRECT_URI,
167+
response_type: "code",
168+
scope: SCOPE_GL,
169+
state,
170+
code_challenge,
171+
code_challenge_method: "S256",
172+
});
173+
174+
const auth_url = buildUrl(base_url, "/oauth/authorize", params);
175+
console.debug("Authorize URL:", auth_url);
176+
location.assign(auth_url);
177+
}
178+
}
179+
180+
// OAuth Callback
181+
async function handleCallback() {
182+
const url = new URL(location.href);
183+
const code = url.searchParams.get("code");
184+
const state = url.searchParams.get("state");
185+
186+
const savedState = JSON.parse(sessionStorage.getItem(`pkce_${state}`));
187+
if (!savedState) return;
188+
const codeVerifier = savedState["code_verifier"];
189+
190+
console.debug("Callback:", { savedState, origin: location.origin, state, storageKeys: Object.keys(sessionStorage), code, url, codeVerifier });
191+
192+
if (!code) return;
193+
194+
console.debug("Got code", code)
195+
196+
const platform = User.getGitPlatform();
197+
if (!platform) {
198+
console.debug("No Git Platform saved. Callback invalid.");
199+
location.reload();
200+
return;
201+
}
202+
203+
const base_url = platform.baseUrl
204+
const client_id = platform.clientId
205+
206+
console.debug("Getting token from", platform.label)
207+
208+
const body = new URLSearchParams({
209+
client_id: client_id,
210+
grant_type: "authorization_code",
211+
code,
212+
redirect_uri: REDIRECT_URI,
213+
code_verifier: codeVerifier,
214+
});
215+
let tokenUrl = `${base_url}/oauth/token`;
216+
if (platform.host == "github") {
217+
// tokenUrl = "/cgi-bin/github-token.py";
218+
// tokenUrl = "https://github.com/login/oauth/access_token"
219+
throw new Error("GitHub Oauth callbacks are not supported");
220+
}
221+
console.debug("Fetching from ", tokenUrl);
222+
const resp = await fetch(tokenUrl, {
223+
method: "POST",
224+
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
225+
body
226+
});
227+
228+
// Check response
229+
const raw = await resp.text();
230+
console.debug("Token raw response:", raw || "(empty)");
231+
if (!resp.ok) {
232+
throw new Error(`Git Host Error ${resp.status}: ${raw || "(empty body)"}`);
233+
}
234+
if (!raw) {
235+
throw new Error("Git Host returned empty body");
236+
}
237+
238+
// Get token from response
239+
let token;
240+
try {
241+
token = JSON.parse(raw);
242+
} catch (e) {
243+
throw new Error("Token response is not valid JSON: " + raw);
244+
}
245+
246+
// clean up url
247+
history.replaceState({}, "", REDIRECT_URI);
248+
249+
// save token
250+
var tokenInput = document.getElementById("token-input");
251+
var saveButton = document.getElementById("token-save-button");
252+
console.debug("Token received: ", token.access_token)
253+
tokenInput.value = token.access_token;
254+
saveButton.onclick();
255+
}
256+
257+
// Connect OAuth button
258+
document.getElementById("oauth-button").onclick = () => startLoginWithOAuth();
259+
260+
// build platform selection
261+
const select = document.getElementById("platform-select");
262+
for (const [key, entry] of Object.entries(User.PLATFORMS)) {
263+
const opt = document.createElement("option");
264+
opt.value = key;
265+
opt.textContent = entry.label;
266+
select.appendChild(opt);
267+
}
268+
// call once to enforce the default hidden state
269+
onPlatformSelected(User.getGitPlatformName() || select.value || null);
270+
// update when the user changes the selection
271+
select.addEventListener("change", (e) => {
272+
onPlatformSelected(e.target.value || null);
273+
});
274+
275+
handleCallback().catch(err => {
276+
alert("Error:\n" + (err?.message || err));
277+
location.reload();
278+
return;
279+
});
280+
};

public/gitlab-setup/index.html

Lines changed: 0 additions & 38 deletions
This file was deleted.

0 commit comments

Comments
 (0)