diff --git a/.RELEASE.md b/.RELEASE.md new file mode 100644 index 0000000..54400a4 --- /dev/null +++ b/.RELEASE.md @@ -0,0 +1 @@ +- Add Gitea provider ([#265](https://github.com/pilcrowonpaper/arctic/pull/265)). diff --git a/README.md b/README.md index 9d0d94c..86d2ed9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Arctic does not strictly follow semantic versioning. While we aim to only introd - Epic Games - Facebook - Figma +- Gitea - GitHub - GitLab - Google diff --git a/docs/malta.config.json b/docs/malta.config.json index 66dc6a6..1ce9729 100644 --- a/docs/malta.config.json +++ b/docs/malta.config.json @@ -36,6 +36,7 @@ ["Etsy", "/providers/etsy"], ["Facebook", "/providers/facebook"], ["Figma", "/providers/figma"], + ["Gitea", "/providers/gitea"], ["GitHub", "/providers/github"], ["GitLab", "/providers/gitlab"], ["Google", "/providers/google"], diff --git a/docs/pages/providers/gitea.md b/docs/pages/providers/gitea.md new file mode 100644 index 0000000..7733f82 --- /dev/null +++ b/docs/pages/providers/gitea.md @@ -0,0 +1,101 @@ +--- +title: "Gitea" +--- + +# Gitea + +OAuth 2.0 provider for Gitea. + +Also see [OAuth 2.0 with PKCE](/guides/oauth2-pkce). + +## Initialization + +The `baseURL` parameter is the full URL where the Gitea instance is hosted. Use `https://gitea.com` for managed servers. Pass the client secret for confidential clients. + +```ts +import * as arctic from "arctic"; + +const baseURL = "https://gitea.com"; +const baseURL = "https://my-app.com/gitea"; +const gitea = new arctic.gitea(baseURL, clientId, clientSecret, redirectURI); +const gitea = new arctic.gitea(baseURL, clientId, null, redirectURI); +``` + +## Create authorization URL + +```ts +import * as arctic from "arctic"; + +const state = arctic.generateState(); +const codeVerifier = arctic.generateCodeVerifier(); +const scopes = ["read:user", "write:notification"]; +const url = gitea.createAuthorizationURL(state, codeVerifier, scopes); +``` + +## Validate authorization code + +`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), [`UnexpectedResponseError`](/reference/main/UnexpectedResponseError), or [`UnexpectedErrorResponseBodyError`](/reference/main/UnexpectedErrorResponseBodyError). Gitea returns an access token, the access token expiration, and a refresh token. + +```ts +import * as arctic from "arctic"; + +try { + const tokens = await gitea.validateAuthorizationCode(code, codeVerifier); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof arctic.OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + const code = e.code; + // ... + } + if (e instanceof arctic.ArcticFetchError) { + // Failed to call `fetch()` + const cause = e.cause; + // ... + } + // Parse error +} +``` + +## Refresh access tokens + +Use `refreshAccessToken()` to get a new access token using a refresh token. This method's behavior is identical to `validateAuthorizationCode()`. + +```ts +import * as arctic from "arctic"; + +try { + const tokens = await gitea.refreshAccessToken(refreshToken); + const accessToken = tokens.accessToken(); + const accessTokenExpiresAt = tokens.accessTokenExpiresAt(); + const refreshToken = tokens.refreshToken(); +} catch (e) { + if (e instanceof arctic.OAuth2RequestError) { + // Invalid authorization code, credentials, or redirect URI + } + if (e instanceof arctic.ArcticFetchError) { + // Failed to call `fetch()` + } + // Parse error +} +``` + +## Get user profile + +Add the `read:user` scope and use the [`/user` endpoint](https://gitea.com/api/swagger#/user). + +```ts +const scopes = ["read:user"]; +const url = gitea.createAuthorizationURL(state, codeVerifier, scopes); +``` + +```ts +const response = await fetch("https://gitea.com/user", { + headers: { + Authorization: `Bearer ${accessToken}` + } +}); +const user = await response.json(); +``` diff --git a/package.json b/package.json index 4087a05..75c1f80 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arctic", "type": "module", - "version": "3.2.4", + "version": "3.3.0", "description": "OAuth 2.0 clients for popular providers", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 5a789e9..fe89ea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,10 +16,11 @@ export { Etsy } from "./providers/etsy.js"; export { EpicGames } from "./providers/epicgames.js"; export { Facebook } from "./providers/facebook.js"; export { Figma } from "./providers/figma.js"; -export { Intuit } from "./providers/intuit.js"; +export { Gitea } from "./providers/gitea.js"; export { GitHub } from "./providers/github.js"; export { GitLab } from "./providers/gitlab.js"; export { Google } from "./providers/google.js"; +export { Intuit } from "./providers/intuit.js"; export { Kakao } from "./providers/kakao.js"; export { KeyCloak } from "./providers/keycloak.js"; export { Lichess } from "./providers/lichess.js"; diff --git a/src/providers/gitea.ts b/src/providers/gitea.ts new file mode 100644 index 0000000..7adcf4c --- /dev/null +++ b/src/providers/gitea.ts @@ -0,0 +1,45 @@ +import { CodeChallengeMethod, OAuth2Client } from "../client.js"; +import { joinURIAndPath } from "../request.js"; + +import type { OAuth2Tokens } from "../oauth2.js"; + +export class Gitea { + private authorizationEndpoint: string; + private tokenEndpoint: string; + + private client: OAuth2Client; + + constructor(baseURL: string, clientId: string, clientSecret: string | null, redirectURI: string) { + this.authorizationEndpoint = joinURIAndPath(baseURL, "/login/oauth/authorize"); + this.tokenEndpoint = joinURIAndPath(baseURL, "/login/oauth/access_token"); + this.client = new OAuth2Client(clientId, clientSecret, redirectURI); + } + + public createAuthorizationURL(state: string, codeVerifier: string, scopes: string[]): URL { + const url = this.client.createAuthorizationURLWithPKCE( + this.authorizationEndpoint, + state, + CodeChallengeMethod.S256, + codeVerifier, + scopes + ); + return url; + } + + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const tokens = await this.client.validateAuthorizationCode( + this.tokenEndpoint, + code, + codeVerifier + ); + return tokens; + } + + public async refreshAccessToken(refreshToken: string): Promise { + const tokens = await this.client.refreshAccessToken(this.tokenEndpoint, refreshToken, []); + return tokens; + } +}