UNPKG

@lucasroll62/nuxt3-auth

Version:

An alternative module to @nuxtjs/auth

389 lines (388 loc) 13.5 kB
import { ExpiredAuthSessionError, IdToken, RefreshController, RefreshToken, RequestHandler, Token } from "../inc/index.mjs"; import { getProp, normalizePath, parseQuery, randomString, removeTokenPrefix } from "../../utils"; import { joinURL, withQuery } from "ufo"; import { useRoute, useRuntimeConfig } from "#imports"; import { BaseScheme } from "./base.mjs"; import requrl from "requrl"; const DEFAULTS = { name: "oauth2", accessType: void 0, redirectUri: void 0, logoutRedirectUri: void 0, clientId: void 0, clientSecretTransport: "body", audience: void 0, grantType: void 0, responseMode: void 0, acrValues: void 0, autoLogout: false, idToken: { property: "id_token", maxAge: 1800, prefix: "_id_token.", expirationPrefix: "_id_token_expiration." }, endpoints: { logout: void 0, authorization: void 0, token: void 0, userInfo: void 0 }, scope: [], token: { property: "access_token", type: "Bearer", name: "Authorization", maxAge: 1800, global: true, prefix: "_token.", expirationPrefix: "_token_expiration." }, refreshToken: { property: "refresh_token", maxAge: 60 * 60 * 24 * 30, prefix: "_refresh_token.", expirationPrefix: "_refresh_token_expiration." }, user: { property: false }, responseType: "token", codeChallengeMethod: "implicit", clientWindow: false, clientWindowWidth: 400, clientWindowHeight: 600 }; export class Oauth2Scheme extends BaseScheme { constructor($auth, options, ...defaults) { super($auth, options, ...defaults, DEFAULTS); this.req = process.server ? $auth.ctx.ssrContext.event.node.req : void 0; this.idToken = new IdToken(this, this.$auth.$storage); this.token = new Token(this, this.$auth.$storage); this.refreshToken = new RefreshToken(this, this.$auth.$storage); this.refreshController = new RefreshController(this); this.requestHandler = new RequestHandler(this, this.$auth.ctx.$http); this.#clientWindowReference = null; } #clientWindowReference; get scope() { return Array.isArray(this.options.scope) ? this.options.scope.join(" ") : this.options.scope; } get redirectURI() { const basePath = useRuntimeConfig().app.baseURL || ""; const path = normalizePath(basePath + "/" + this.$auth.options.redirect.callback); return this.options.redirectUri || joinURL(requrl(this.req), path); } get logoutRedirectURI() { return this.options.logoutRedirectUri || joinURL(requrl(this.req), this.$auth.options.redirect.logout); } check(checkStatus = false) { const response = { valid: false, tokenExpired: false, refreshTokenExpired: false, isRefreshable: true, idTokenExpired: false }; const token = this.token.sync(); this.refreshToken.sync(); this.idToken.sync(); if (!token) { return response; } if (!checkStatus) { response.valid = true; return response; } const tokenStatus = this.token.status(); const refreshTokenStatus = this.refreshToken.status(); const idTokenStatus = this.idToken.status(); if (refreshTokenStatus.expired()) { response.refreshTokenExpired = true; return response; } if (tokenStatus.expired()) { response.tokenExpired = true; return response; } if (idTokenStatus.expired()) { response.idTokenExpired = true; return response; } response.valid = true; return response; } async mounted() { const { tokenExpired, refreshTokenExpired } = this.check(true); if (refreshTokenExpired || tokenExpired && this.options.autoLogout) { this.$auth.reset(); } this.requestHandler.initializeRequestInterceptor( this.options.endpoints.token ); const redirected = await this.#handleCallback(); if (!redirected) { return this.$auth.fetchUserOnce(); } } reset() { this.$auth.setUser(false); this.token.reset(); this.refreshToken.reset(); this.requestHandler.reset(); this.idToken.reset(); } async login($opts = {}) { const opts = { protocol: "oauth2", response_type: this.options.responseType, access_type: this.options.accessType, client_id: this.options.clientId, redirect_uri: this.redirectURI, scope: this.scope, state: $opts.state || randomString(10), code_challenge_method: this.options.codeChallengeMethod, clientWindow: this.options.clientWindow, clientWindowWidth: this.options.clientWindowWidth, clientWindowHeight: this.options.clientWindowHeight, ...$opts.params }; if (this.options.organization) { opts.organization = this.options.organization; } if (this.options.audience) { opts.audience = this.options.audience; } if (opts.clientWindow) { if (this.#clientWindowReference === null || this.#clientWindowReference?.closed) { const windowFeatures = this.clientWindowFeatures(opts.clientWindowWidth, opts.clientWindowHeight); this.#clientWindowReference = globalThis.open("about:blank", "oauth2-client-window", windowFeatures); let strategy = this.$auth.$state.strategy; let listener = this.clientWindowCallback.bind(this); globalThis.addEventListener("message", listener); let checkPopUpInterval = setInterval(() => { if (this.#clientWindowReference?.closed || strategy !== this.$auth.$state.strategy) { globalThis.removeEventListener("message", listener); this.#clientWindowReference = null; clearInterval(checkPopUpInterval); } }, 500); } else { this.#clientWindowReference.focus(); } } if (opts.response_type.includes("token") || opts.response_type.includes("id_token")) { opts.nonce = $opts.nonce || randomString(10); } if (opts.code_challenge_method) { switch (opts.code_challenge_method) { case "plain": case "S256": { const state = this.generateRandomString(); this.$auth.$storage.setUniversal(this.name + ".pkce_state", state); const codeVerifier = this.generateRandomString(); this.$auth.$storage.setUniversal(this.name + ".pkce_code_verifier", codeVerifier); const codeChallenge = await this.pkceChallengeFromVerifier(codeVerifier, opts.code_challenge_method === "S256"); opts.code_challenge = globalThis.encodeURIComponent(codeChallenge); } break; case "implicit": default: break; } } if (this.options.responseMode) { opts.response_mode = this.options.responseMode; } if (this.options.acrValues) { opts.acr_values = this.options.acrValues; } this.$auth.$storage.setUniversal(this.name + ".state", opts.state); const url = withQuery(this.options.endpoints.authorization, opts); if (opts.clientWindow) { if (this.#clientWindowReference) { this.#clientWindowReference.location = url; } } else { globalThis.location.replace(url); } } clientWindowCallback(event) { const isLogInSuccessful = !!event.data.isLoggedIn; if (isLogInSuccessful) { this.$auth.fetchUserOnce(); } } clientWindowFeatures(clientWindowWidth, clientWindowHeight) { const top = globalThis.top.outerHeight / 2 + globalThis.top.screenY - clientWindowHeight / 2; const left = globalThis.top.outerWidth / 2 + globalThis.top.screenX - clientWindowWidth / 2; return `toolbar=no, menubar=no, width=${clientWindowWidth}, height=${clientWindowHeight}, top=${top}, left=${left}`; } logout() { if (this.options.endpoints.logout) { const opts = { id_token_hint: this.idToken.get(), post_logout_redirect_uri: this.logoutRedirectURI }; const url = withQuery(this.options.endpoints.logout, opts); window.location.replace(url); } return this.$auth.reset(); } async fetchUser() { if (!this.check().valid) { return; } if (!this.options.fetchRemote && this.idToken.get()) { const data2 = this.idToken.userInfo(); this.$auth.setUser(data2); return; } if (!this.options.endpoints.userInfo) { this.$auth.setUser({}); return; } const response = await this.$auth.requestWith({ url: this.options.endpoints.userInfo }); this.$auth.setUser(getProp(response, this.options.user.property)); } async #handleCallback() { const route = useRoute(); if (this.$auth.options.redirect && normalizePath(route.path) !== normalizePath(this.$auth.options.redirect.callback)) { return; } if (process.server) { return; } const hash = parseQuery(route.hash.slice(1)); const parsedQuery = Object.assign({}, route.query, hash); let token = parsedQuery[this.options.token.property]; let refreshToken; if (this.options.refreshToken.property) { refreshToken = parsedQuery[this.options.refreshToken.property]; } let idToken = parsedQuery[this.options.idToken.property]; const state = this.$auth.$storage.getUniversal(this.name + ".state"); this.$auth.$storage.setUniversal(this.name + ".state", null); if (state && parsedQuery.state !== state) { return; } if (this.options.responseType.includes("code") && parsedQuery.code) { let codeVerifier; if (this.options.codeChallengeMethod && this.options.codeChallengeMethod !== "implicit") { codeVerifier = this.$auth.$storage.getUniversal(this.name + ".pkce_code_verifier"); this.$auth.$storage.setUniversal(this.name + ".pkce_code_verifier", null); } const response = await this.$auth.request({ method: "post", url: this.options.endpoints.token, baseURL: "", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code: parsedQuery.code, client_id: this.options.clientId, redirect_uri: this.redirectURI, response_type: this.options.responseType, audience: this.options.audience, grant_type: this.options.grantType, code_verifier: codeVerifier }) }); token = getProp(response, this.options.token.property) || token; refreshToken = getProp(response, this.options.refreshToken.property) || refreshToken; idToken = getProp(response, this.options.idToken.property) || idToken; } if (!token || !token.length) { return; } this.token.set(token); if (refreshToken && refreshToken.length) { this.refreshToken.set(refreshToken); } if (idToken && idToken.length) { this.idToken.set(idToken); } if (this.options.clientWindow) { if (globalThis.opener) { globalThis.opener.postMessage({ isLoggedIn: true }); globalThis.close(); } } else if (this.$auth.options.watchLoggedIn) { this.$auth.redirect("home", false, false); return true; } } async refreshTokens() { const refreshToken = this.refreshToken.get(); if (!refreshToken) { return; } const refreshTokenStatus = this.refreshToken.status(); if (refreshTokenStatus.expired()) { this.$auth.reset(); throw new ExpiredAuthSessionError(); } this.requestHandler.clearHeader(); const response = await this.$auth.request({ method: "post", url: this.options.endpoints.token, baseURL: "", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ refresh_token: removeTokenPrefix(refreshToken, this.options.token.type), scope: this.scope, client_id: this.options.clientId, grant_type: "refresh_token" }) }).catch((error) => { this.$auth.callOnError(error, { method: "refreshToken" }); return Promise.reject(error); }); this.updateTokens(response); return response; } updateTokens(response) { const token = getProp(response, this.options.token.property); const refreshToken = getProp(response, this.options.refreshToken.property); const [header, payload, signature] = response.access_token.split("."); const decodedPayload = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); const parsed = JSON.parse(decodedPayload); if (parsed.exp * 1e3 <= Date.now()) { console.log("Token expired. Refreshing..."); this.token.set(token); const idToken = getProp(response, this.options.idToken.property); if (idToken) { this.idToken.set(idToken); } if (refreshToken) { this.refreshToken.set(refreshToken); } } } async pkceChallengeFromVerifier(v, hashValue) { if (hashValue) { const hashed = await this.#sha256(v); return this.#base64UrlEncode(hashed); } return v; } generateRandomString() { const array = new Uint32Array(28); globalThis.crypto.getRandomValues(array); return Array.from(array, (dec) => ("0" + dec.toString(16)).slice(-2)).join(""); } #sha256(plain) { const encoder = new TextEncoder(); const data = encoder.encode(plain); return globalThis.crypto.subtle.digest("SHA-256", data); } #base64UrlEncode(str) { return btoa(String.fromCharCode.apply(null, new Uint8Array(str))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } }