Depot API & v4 (#298)
* feat: nginx + torrential basics & services system * fix: lint + i18n * fix: update torrential to remove openssl * feat: add torrential to Docker build * feat: move to self hosted runner * fix: move off self-hosted runner * fix: update nginx.conf * feat: torrential cache invalidation * fix: update torrential for cache invalidation * feat: integrity check task * fix: lint * feat: move to version ids * fix: client fixes and client-side checks * feat: new depot apis and version id fixes * feat: update torrential * feat: droplet bump and remove unsafe update functions * fix: lint * feat: v4 featureset: emulators, multi-launch commands * fix: lint * fix: mobile ui for game editor * feat: launch options * fix: lint * fix: remove axios, use $fetch * feat: metadata and task api improvements * feat: task actions * fix: slight styling issue * feat: fix style and lints * feat: totp backend routes * feat: oidc groups * fix: update drop-base * feat: creation of passkeys & totp * feat: totp signin * feat: webauthn mfa/signin * feat: launch selecting ui * fix: manually running tasks * feat: update add company game modal to use new SelectorGame * feat: executor selector * fix(docker): update rust to rust nightly for torrential build (#305) * feat: new version ui * feat: move package lookup to build time to allow for deno dev * fix: lint * feat: localisation cleanup * feat: apply localisation cleanup * feat: potential i18n refactor logic * feat: remove args from commands * fix: lint * fix: lockfile --------- Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
export function b32e(array: Uint8Array): string;
|
||||
export function b32d(str: string): Uint8Array;
|
||||
@@ -0,0 +1,69 @@
|
||||
// base32 elements
|
||||
//RFC4648: why include 2? Z and 2 looks similar than 8 and O
|
||||
const b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
console.assert(b32.length === 32, b32.length);
|
||||
const b32r = new Map(Array.from(b32, (ch, i) => [ch, i])).set("=", 0);
|
||||
//[constants derived from character table size]
|
||||
//cbit = 5 (as 32 == 2 ** 5), ubit = 8 (as byte)
|
||||
//ccount = 8 (= cbit / gcd(cbit, ubit)), ucount = 5 (= ubit / gcd(cbit, ubit))
|
||||
//cmask = 0x1f (= 2 ** cbit - 1), umask = 0xff (= 2 ** ubit - 1)
|
||||
//const b32pad = [0, 6, 4, 3, 1];
|
||||
const b32pad = Array.from(Array(5), (_, i) => ((8 - (i * 8) / 5) | 0) % 8);
|
||||
|
||||
function b32e5(u1, u2 = 0, u3 = 0, u4 = 0, u5 = 0) {
|
||||
const u40 = u1 * 2 ** 32 + u2 * 2 ** 24 + u3 * 2 ** 16 + u4 * 2 ** 8 + u5;
|
||||
return [
|
||||
b32[(u40 / 2 ** 35) & 0x1f],
|
||||
b32[(u40 / 2 ** 30) & 0x1f],
|
||||
b32[(u40 / 2 ** 25) & 0x1f],
|
||||
b32[(u40 / 2 ** 20) & 0x1f],
|
||||
b32[(u40 / 2 ** 15) & 0x1f],
|
||||
b32[(u40 / 2 ** 10) & 0x1f],
|
||||
b32[(u40 / 2 ** 5) & 0x1f],
|
||||
b32[u40 & 0x1f],
|
||||
];
|
||||
}
|
||||
function b32d8(b1, b2, b3, b4, b5, b6, b7, b8) {
|
||||
const u40 =
|
||||
b32r.get(b1) * 2 ** 35 +
|
||||
b32r.get(b2) * 2 ** 30 +
|
||||
b32r.get(b3) * 2 ** 25 +
|
||||
b32r.get(b4) * 2 ** 20 +
|
||||
b32r.get(b5) * 2 ** 15 +
|
||||
b32r.get(b6) * 2 ** 10 +
|
||||
b32r.get(b7) * 2 ** 5 +
|
||||
b32r.get(b8);
|
||||
return [
|
||||
(u40 / 2 ** 32) & 0xff,
|
||||
(u40 / 2 ** 24) & 0xff,
|
||||
(u40 / 2 ** 16) & 0xff,
|
||||
(u40 / 2 ** 8) & 0xff,
|
||||
u40 & 0xff,
|
||||
];
|
||||
}
|
||||
|
||||
// base32 encode/decode: Uint8Array <=> string
|
||||
export function b32e(u8a) {
|
||||
console.assert(u8a instanceof Uint8Array, u8a.constructor);
|
||||
const len = u8a.length,
|
||||
rem = len % 5;
|
||||
const u5s = Array.from(Array((len - rem) / 5), (_, i) =>
|
||||
u8a.subarray(i * 5, i * 5 + 5),
|
||||
);
|
||||
const pad = b32pad[rem];
|
||||
const br = rem === 0 ? [] : b32e5(...u8a.subarray(-rem)).slice(0, 8 - pad);
|
||||
return []
|
||||
.concat(...u5s.map((u5) => b32e5(...u5)), br, ["=".repeat(pad)])
|
||||
.join("");
|
||||
}
|
||||
export function b32d(bs) {
|
||||
const len = bs.length;
|
||||
if (len === 0) return new Uint8Array([]);
|
||||
console.assert(len % 8 === 0, len);
|
||||
const pad = len - bs.indexOf("="),
|
||||
rem = b32pad.indexOf(pad);
|
||||
console.assert(rem >= 0, pad);
|
||||
console.assert(/^[A-Z2-7+/]*$/.test(bs.slice(0, len - pad)), bs);
|
||||
const u8s = [].concat(...bs.match(/.{8}/g).map((b8) => b32d8(...b8)));
|
||||
return new Uint8Array(rem > 0 ? u8s.slice(0, rem - 5) : u8s);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class AuthManager {
|
||||
(this.authProviders as any)[key] = object;
|
||||
logger.info(`enabled auth: ${key}`);
|
||||
} catch (e) {
|
||||
logger.warn(e);
|
||||
logger.warn((e as string).toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export class OIDCManager {
|
||||
private clientSecret: string;
|
||||
private externalUrl: string;
|
||||
|
||||
private userGroup?: string = process.env.OIDC_USER_GROUP;
|
||||
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
|
||||
private usernameClaim: keyof OIDCUserInfo =
|
||||
(process.env.OIDC_USERNAME_CLAIM as keyof OIDCUserInfo) ??
|
||||
@@ -204,11 +205,11 @@ export class OIDCManager {
|
||||
},
|
||||
});
|
||||
|
||||
const user = await this.fetchOrCreateUser(userinfo);
|
||||
const userOrError = await this.fetchOrCreateUser(userinfo);
|
||||
|
||||
if (typeof user === "string") return user;
|
||||
if (typeof userOrError === "string") return userOrError;
|
||||
|
||||
return { user, options: session.options };
|
||||
return { user: userOrError, options: session.options };
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return `Request to identity provider failed: ${e}`;
|
||||
@@ -236,6 +237,19 @@ export class OIDCManager {
|
||||
if (!username)
|
||||
return "Invalid username claim in OIDC response: " + this.usernameClaim;
|
||||
|
||||
const isAdmin =
|
||||
userinfo.groups !== undefined &&
|
||||
this.adminGroup !== undefined &&
|
||||
userinfo.groups.includes(this.adminGroup);
|
||||
|
||||
const isUser = this.userGroup
|
||||
? userinfo.groups !== undefined &&
|
||||
userinfo.groups.includes(this.userGroup)
|
||||
: true;
|
||||
|
||||
if (!(isAdmin || isUser))
|
||||
return "Not authorized to access this application.";
|
||||
|
||||
/*
|
||||
const takenUsername = await prisma.user.count({
|
||||
where: {
|
||||
@@ -274,11 +288,6 @@ export class OIDCManager {
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin =
|
||||
userinfo.groups !== undefined &&
|
||||
this.adminGroup !== undefined &&
|
||||
userinfo.groups.includes(this.adminGroup);
|
||||
|
||||
const created = await prisma.linkedAuthMec.create({
|
||||
data: {
|
||||
mec: AuthMec.OpenID,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function dropEncodeArrayBase64(secret: Uint8Array): string {
|
||||
return encode(secret);
|
||||
}
|
||||
export function dropDecodeArrayBase64(secret: string): Uint8Array {
|
||||
return decode(secret);
|
||||
}
|
||||
|
||||
const { fromCharCode } = String;
|
||||
const encode = (uint8array: Uint8Array) => {
|
||||
const output = [];
|
||||
for (let i = 0, { length } = uint8array; i < length; i++)
|
||||
output.push(fromCharCode(uint8array[i]));
|
||||
return btoa(output.join(""));
|
||||
};
|
||||
|
||||
const asCharCode = (c: string) => c.charCodeAt(0);
|
||||
|
||||
const decode = (chars: string) => Uint8Array.from(atob(chars), asCharCode);
|
||||
|
||||
export interface TOTPv1Credentials {
|
||||
secret: string;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ArkErrors, type } from "arktype";
|
||||
import { systemConfig } from "../config/sys-conf";
|
||||
import { dropDecodeArrayBase64 } from "./totp";
|
||||
import { decode } from "cbor2";
|
||||
import { createHash } from "node:crypto";
|
||||
import cosekey from "parse-cosekey";
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||
|
||||
export async function getRpId() {
|
||||
const externalUrl =
|
||||
process.env.WEBAUTHN_DOMAIN ?? (await systemConfig.getExternalUrl());
|
||||
const externalUrlParsed = new URL(externalUrl);
|
||||
|
||||
return externalUrlParsed.hostname;
|
||||
}
|
||||
|
||||
export interface Passkey {
|
||||
name: string;
|
||||
created: number;
|
||||
userId: string;
|
||||
webAuthnUserId: string;
|
||||
id: string;
|
||||
publicKey: string;
|
||||
counter: number;
|
||||
transports: Array<AuthenticatorTransportFuture> | undefined;
|
||||
deviceType: string;
|
||||
backedUp: boolean;
|
||||
}
|
||||
|
||||
export interface WebAuthNv1Credentials {
|
||||
passkeys: Array<Passkey>;
|
||||
}
|
||||
|
||||
const ClientData = type({
|
||||
type: "'webauthn.create'",
|
||||
challenge: "string",
|
||||
origin: "string",
|
||||
});
|
||||
|
||||
const AuthData = type({
|
||||
fmt: "string",
|
||||
authData: "TypedArray.Uint8",
|
||||
});
|
||||
|
||||
export async function parseAndValidatePasskeyCreation(
|
||||
clientDataString: string,
|
||||
attestationObjectString: string,
|
||||
challenge: string,
|
||||
) {
|
||||
const clientData = dropDecodeArrayBase64(clientDataString);
|
||||
const attestationObject = dropDecodeArrayBase64(attestationObjectString);
|
||||
|
||||
const utf8Decoder = new TextDecoder("utf-8");
|
||||
const decodedClientData = utf8Decoder.decode(clientData);
|
||||
const clientDataObj = ClientData(JSON.parse(decodedClientData));
|
||||
if (clientDataObj instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid client data JSON object: ${clientDataObj.summary}`,
|
||||
});
|
||||
|
||||
const convertedChallenge = Buffer.from(
|
||||
dropDecodeArrayBase64(clientDataObj.challenge),
|
||||
).toString("utf8");
|
||||
|
||||
if (convertedChallenge !== challenge)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Challenge does not match.",
|
||||
});
|
||||
|
||||
const tmp = decode(attestationObject);
|
||||
const decodedAttestationObject = AuthData(tmp);
|
||||
if (decodedAttestationObject instanceof ArkErrors)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `Invalid attestation object: ${decodedAttestationObject.summary}`,
|
||||
});
|
||||
|
||||
const userRpIdHash = decodedAttestationObject.authData.slice(0, 32);
|
||||
const rpId = await getRpId();
|
||||
const rpIdHash = createHash("sha256").update(rpId).digest();
|
||||
|
||||
if (!rpIdHash.equals(userRpIdHash))
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Incorrect relying party ID",
|
||||
});
|
||||
|
||||
const attestedCredentialData = decodedAttestationObject.authData.slice(37);
|
||||
if (attestedCredentialData.length < 18)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message:
|
||||
"Attested credential data is missing AAGUID and/or credentialIdLength",
|
||||
});
|
||||
// const aaguid = attestedCredentialData.slice(0, 16);
|
||||
const credentialIdLengthBuffer = attestedCredentialData.slice(16, 18);
|
||||
const credentialIdLength = Buffer.from(credentialIdLengthBuffer).readUintBE(
|
||||
0,
|
||||
2,
|
||||
);
|
||||
if (attestedCredentialData.length < 18 + credentialIdLength)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Missing credential data of length: " + credentialIdLength,
|
||||
});
|
||||
const credentialId = attestedCredentialData.slice(
|
||||
18,
|
||||
18 + credentialIdLength,
|
||||
);
|
||||
const credentialPublicKey: Map<number, number> = decode(
|
||||
attestedCredentialData.slice(18 + credentialIdLength),
|
||||
);
|
||||
if (!(credentialPublicKey instanceof Map))
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: "Could not decode public key from attestion credential data",
|
||||
});
|
||||
|
||||
const credentialIdStr = Buffer.from(credentialId).toString("hex");
|
||||
const jwk = cosekey.KeyParser.cose2jwk(credentialPublicKey);
|
||||
|
||||
return {
|
||||
credentialIdStr,
|
||||
jwk,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user