SchoolSoft+ LogoSchoolSoft+DEVELOPER
On this page
REVERSE ENGINEERED

AuthV2

AuthV2 is the OAuth 2.0 Authorization Code flow with PKCEused by SchoolSoft's mobile applications. It enables long-lived, token-based authentication without ever storing raw credentials — exactly how the official SchoolSoft app avoids asking you to log in every hour.

AuthV2 was discovered through weeks of reverse engineeringSchoolSoft's mobile app network traffic. This is not an officially documented API. Endpoints and parameters may change without notice.

Introduction

SchoolSoft exposes two distinct authentication surfaces:

  • Web authentication — Classic session-based login using JSESSIONID cookies. Valid for roughly one hour.
  • Mobile authentication (AuthV2) — A full OAuth 2.0 + PKCE flow that returns a long-lived Bearer token valid for up to 30 days. This is what the official SchoolSoft app uses.

SchoolSoft+ uses AuthV2 to give apps and integrations the same long-lived access the official mobile app enjoys — without storing your password anywhere.

Why it matters

Session-based authentication is fine for a browser tab you have open, but it falls apart for mobile apps and background services:

  • Sessions expire after ~1 hour, forcing the user to log in again.
  • Background operations (schedule syncing, push notifications) break when the session expires silently.
  • Storing the username and password to re-authenticate automatically is a serious security risk and defeats the purpose of session auth.

AuthV2 solves this by replacing the short-lived cookie with a token that lives for 30 days. The token can be cached, refreshed, and revoked server-side — without the user ever re-entering their password.

Web vs. Mobile authentication

Understanding the difference between the two auth systems helps clarify why AuthV2 exists.

Web (Session-based)

  • POST username + password
  • Server returns JSESSIONID cookie
  • Cookie expires in ~1 hour
  • Requires re-login after expiry
  • No token concept

Mobile (AuthV2 / PKCE)

  • POST credentials + PKCE challenge
  • Server returns short-lived auth code
  • Code is exchanged for Bearer token
  • Token valid for 30 days
  • Token can be refreshed silently

AuthV2 can also translate a Bearer token into a session cookie, giving you access to the full regular web API as well.

What is PKCE?

PKCE stands for Proof Key for Code Exchange. It is an extension to OAuth 2.0 designed specifically to protect public clients — apps that cannot safely store a client secret (mobile apps, single-page apps, desktop apps).

Without PKCE, an attacker who intercepts the authorization code (for example via a misconfigured redirect URI) could immediately exchange it for a token. PKCE prevents this by requiring the original requester to prove they initiated the flow.

How PKCE works

PKCE adds a temporary secret to the standard OAuth code flow:

1

Generate code_verifier

The app generates a cryptographically random string called the code_verifier. This is the raw secret — it never leaves the app at this stage.

2

Derive code_challenge

The app computes SHA-256(code_verifier) and Base64url-encodes the result. This is the code_challenge — a one-way hash that can be sent safely.

3

Send challenge to server

The authorization request includes the code_challenge and code_challenge_method=S256. The server stores the challenge alongside the pending auth request.

4

Receive authorization code

If credentials are valid, the server returns a short-lived, one-time-use authorization code.

5

Exchange code + verifier for token

The app sends the code and the original code_verifier. The server recomputes SHA-256(code_verifier) and checks it matches the stored challenge.

6

Token issued

If the hashes match, the server issues the access token. The code alone is useless without the verifier — even if intercepted.

typescript
// Simplified PKCE pair generation
import crypto from "crypto";

function makePkcePair() {
  // 1. Generate a random 32-byte verifier, base64url-encoded
  const verifier = crypto
    .randomBytes(32)
    .toString("base64url"); // ~43 chars, no padding

  // 2. SHA-256 hash of the verifier, base64url-encoded
  const challenge = crypto
    .createHash("sha256")
    .update(verifier)
    .digest("base64url");

  return { verifier, challenge };
}

// Usage
const { verifier, challenge } = makePkcePair();
// challenge → sent to SchoolSoft (code_challenge)
// verifier  → kept secret, sent later during token exchange

Why PKCE for mobile?

Native mobile apps are public clients — any secret baked into the app binary can be extracted by a determined attacker. Standard OAuth 2.0 confidential clients rely on a client_secret that only the server and a trusted backend know. That model breaks down entirely for mobile apps.

PKCE replaces the static client secret with a per-requestephemeral secret. There is nothing permanent to steal from the binary. This is why the OAuth 2.0 Security Best Current Practice (RFC 9700) mandates PKCE for all public clients, and why SchoolSoft's mobile app uses it.

AuthV2 flow overview

The full AuthV2 flow from cold start to usable Bearer token:

text
App                             SchoolSoft server
                                      
  makePkcePair()    (local, no network)
                                      
  POST /rest-api/login/student   { username, password,
    /password                             code_challenge, client_id,
                                          redirect_uri, state, ... }
                                      
  { code: "abc123" }   authorization code
                                      
  POST /rest-api/login/token   { clientId, grantType: "code",
                                          code, code_verifier }
                                      
  { access_token, expires_in }   Bearer token (30 days)
                                      
  GET /rest-api/...    Authorization: Bearer <token>
                                      
  (optional) token  session   translate token to JSESSIONID
                                        for web API access

Step 1 — Generate PKCE pair

Before any network call, the client generates a fresh PKCE pair. The code_verifier must be kept in memory only — never logged, never persisted. The code_challenge is safe to send over the network because it is a one-way hash.

typescript
import crypto from "crypto";

export function makePkcePair(): { verifier: string; challenge: string } {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto
    .createHash("sha256")
    .update(verifier)
    .digest("base64url");
  return { verifier, challenge };
}

Step 2 — Authorization request

Send the user's credentials to SchoolSoft's password login endpoint, along with the PKCE challenge and OAuth parameters. This is the only point at which the raw password is used.

typescript
const STATE = crypto.randomBytes(16).toString("hex"); // CSRF protection

const authResponse = await fetch(
  `https://sms.schoolsoft.se/${school}/rest-api/login/student/password`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      username,
      password,
      // PKCE
      code_challenge:        challenge,
      code_challenge_method: "S256",
      // OAuth 2.0
      client_id:    "eApp",         // SchoolSoft mobile app client
      redirect_uri: "eapp://login", // mobile deep-link URI
      response_type: "code",
      state:        STATE,
      orgid:        school,
    }),
  }
);

const data = await authResponse.json();
// data.code — the short-lived authorization code
The password field is used once and then discarded. Never store it in memory longer than this single call.

Step 3 — Receive the authorization code

On a successful authorization request, SchoolSoft returns a JSON body containing a short-lived, one-time-use code. This code is valid for a very short window (seconds to minutes) and can only be used once.

json
{
  "code": "eyJhbGc...",
  "state": "a3f8d1...",
  "redirect_uri": "eapp://login"
}

Verify that the state in the response matches the value you sent in Step 2 before proceeding. A mismatch indicates a possible CSRF attempt.

Step 4 — Token exchange

Exchange the authorization code and the original code_verifier for a long-lived Bearer token. This is the core PKCE proof: SchoolSoft recomputes SHA-256(code_verifier) and checks it against the code_challenge stored in Step 2.

typescript
const tokenResponse = await fetch(
  `https://sms.schoolsoft.se/${school}/rest-api/login/token`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      clientId:      "eApp",
      grantType:     "code",
      code:          data.code,    // from Step 3
      code_verifier: verifier,     // the original random secret from Step 1
    }),
  }
);

const token = await tokenResponse.json();
// {
//   access_token: "eyJhbGc...",
//   token_type:   "Bearer",
//   expires_in:   2592000,        // 30 days in seconds
// }

Step 5 — Use the token

Attach the Bearer token as an Authorization header on every subsequent API request. The token acts as your identity proof and replaces the need for a session cookie on the mobile API.

typescript
const schedule = await fetch(
  `https://sms.schoolsoft.se/${school}/rest-api/schedule/student/${userId}`,
  {
    headers: {
      Authorization: `Bearer ${token.access_token}`,
      "User-Agent":  "Schoolsoft+/1.0",
    },
  }
);

Step 6 — Token → session cookie (optional)

The Bearer token can be translated into a traditional session cookie. This lets you access the full regular web API (including JSP-based HTML scraping endpoints) using the same AuthV2 token without a separate web login.

typescript
// POST to SchoolSoft's session endpoint with the Bearer token
const sessionRes = await fetch(
  `https://sms.schoolsoft.se/${school}/rest-api/login/session`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token.access_token}`,
      "Content-Type": "application/json",
    },
  }
);

// Extract the Set-Cookie headers → JSESSIONID, hash, usertype
const cookies = sessionRes.headers.getSetCookie();
// These cookies can now be used against the regular web API
This translation is what allows SchoolSoft+ to serve both the mobile API (Bearer token) and the web API (session cookie) from a single AuthV2 login.

POST /api/v2/initiate

SchoolSoft+ server-side wrapper: generates a fresh PKCE pair, stores the verifier server-side, and initiates the authorization request against SchoolSoft. Returns the auth code to the caller.

POST /api/v2/callback

Completes the PKCE exchange. Accepts the authorization code returned from the initiation step and the stored state, then calls SchoolSoft's token endpoint with the internally stored code_verifier.

POST /api/v2/token-refresh

Silently refreshes an existing token before it expires. The caller provides the current token; the server handles re-initiating the PKCE flow if a refresh token is available.

POST /api/login

Legacy combined endpoint: runs the full AuthV2 PKCE flow internally (Steps 1–4) and returns both the Bearer token and the translated session cookies in a single call. Designed for apps that want a simple one-shot login with no multi-step handling.

POST /api/token

Returns the raw token response after a prior /api/login call. Useful for clients that need the token value separately from the session cookies.

POST /api/refresh

Legacy token refresh endpoint. Accepts an existing session token and attempts to obtain a new one, extending the session without requiring the user to re-enter their credentials.

GET /api/session

Returns metadata about the currently authenticated session — user ID, name, school, token expiry, and role. Used to verify that a token is still valid without making a full data request.

PKCE utilities

A minimal self-contained PKCE helper using only Node.js built-ins:

typescript
import crypto from "crypto";

/** Generate a PKCE verifier/challenge pair (RFC 7636, S256 method). */
export function makePkcePair(): { verifier: string; challenge: string } {
  const verifier  = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

/** Verify that a received state matches the one we sent. */
export function verifyState(sent: string, received: string): boolean {
  // Use timingSafeEqual to prevent timing attacks
  const a = Buffer.from(sent);
  const b = Buffer.from(received);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Full implementation example

The following example performs the complete AuthV2 flow — from cold start to a usable Bearer token — using only Node.js built-ins:

typescript
import crypto from "crypto";
import https  from "https";

// ── Helpers ───────────────────────────────────────────────────
function makePkcePair() {
  const verifier  = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

async function post(url: string, body: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(body);
    const req = https.request(url, {
      method:  "POST",
      headers: {
        "Content-Type":   "application/json",
        "Content-Length": Buffer.byteLength(payload),
        "User-Agent":     "Schoolsoft+/1.0",
      },
    }, (res) => {
      let raw = "";
      res.on("data", (c) => (raw += c));
      res.on("end",  () => resolve(JSON.parse(raw)));
    });
    req.on("error", reject);
    req.write(payload);
    req.end();
  });
}

// ── AuthV2 Flow ───────────────────────────────────────────────
async function authV2Login(school: string, username: string, password: string) {
  const STATE = crypto.randomBytes(16).toString("hex");
  const { verifier, challenge } = makePkcePair();

  // Step 2: Authorization request
  const authData = await post(
    `https://sms.schoolsoft.se/${school}/rest-api/login/student/password`,
    {
      username,
      password,
      code_challenge:        challenge,
      code_challenge_method: "S256",
      client_id:             "eApp",
      redirect_uri:          "eapp://login",
      response_type:         "code",
      state:                 STATE,
      orgid:                 school,
    }
  ) as { code: string; state: string };

  // Step 3: Verify state (CSRF protection)
  if (authData.state !== STATE) throw new Error("State mismatch — possible CSRF");

  // Step 4: Token exchange
  const tokenData = await post(
    `https://sms.schoolsoft.se/${school}/rest-api/login/token`,
    {
      clientId:      "eApp",
      grantType:     "code",
      code:          authData.code,
      code_verifier: verifier,
    }
  ) as { access_token: string; expires_in: number; token_type: string };

  // The password is no longer referenced after this point.
  return tokenData;
}

// ── Usage ─────────────────────────────────────────────────────
const token = await authV2Login("engelska", "student@example.com", "hunter2");
console.log(`Token (valid ${token.expires_in / 86400} days):`, token.access_token);

Token storage

Because the token is valid for 30 days it needs to be stored somewhere. The rules are simple:

  • Never store in LocalStorage — accessible to any JavaScript on the page (XSS risk).
  • Prefer HttpOnly cookies — the browser stores the token but scripts cannot read it.
  • For native mobile apps — use the platform keychain (iOS Keychain, Android Keystore). Never write raw tokens to shared storage or logs.
  • Server-side sessions — store the token server-side and give the client an opaque session ID. This is what SchoolSoft+ does.

Security model

AuthV2 is designed around the principle that credentials are ephemeral. The password is the identity proof; the token is the capability proof. Once the token is issued, the password is no longer needed.

Credential exposure window

Single HTTP request

Token lifetime

30 days (2 592 000 s)

PKCE verifier scope

Per-request, in-memory only

CSRF protection

Random state parameter

Code replay protection

One-time use + PKCE verifier

Intercept protection

PKCE (no secret in binary)

What is NOT stored

SchoolSoft+ does not store usernames, passwords, or raw credentials anywhere — not in a database, not in LocalStorage, not in logs. The password is forwarded directly to SchoolSoft over HTTPS on the single authorization request and then discarded.

Only the following are persisted (server-side, in an encrypted session):

  • The Bearer access token
  • The token expiry timestamp
  • The school slug and user ID

The code_verifier is held in memory only for the duration of the token exchange (Steps 1–4) and then discarded.

PKCE parameters reference

Parameters

NameTypeRequiredDescription
code_verifierstringYesRandom 32-byte value, base64url-encoded (~43 chars). Generated fresh per login. NEVER sent to the server during authorization — only during token exchange.
code_challengestringYesSHA-256(code_verifier), base64url-encoded. Sent to the authorization endpoint.
code_challenge_methodstringYesMust be "S256" (SHA-256). Plain is not accepted by SchoolSoft.
client_idstringYesAlways "eApp" — the identifier for SchoolSoft's mobile app client.
redirect_uristringYes"eapp://login" — the deep-link URI the mobile app registers. Must match exactly.
response_typestringYesAlways "code" — standard OAuth 2.0 Authorization Code flow.
statestringYesRandom value generated per request. Returned unchanged in the response. Verify this matches before token exchange to prevent CSRF.
orgidstringYesThe school slug (e.g. "engelska"). Scopes the authorization to the correct school.
grantTypestringYes"code" — used during token exchange to identify this as an Authorization Code grant.
clientIdstringYes"eApp" — same as client_id but used in the token exchange request body.

Token response fields

Parameters

NameTypeRequiredDescription
access_tokenstringYesThe Bearer token. Include as Authorization: Bearer <token> on all API requests.
token_typestringYesAlways "Bearer".
expires_innumberYesToken lifetime in seconds. SchoolSoft returns 2592000 (30 days).
scopestringNoOAuth scopes granted. May be absent if SchoolSoft does not return explicit scopes.

TypeScript

Full AuthV2 login flow using Node.js built-ins — no dependencies required.

typescript
import crypto from "crypto";
import https  from "https";

function makePkcePair() {
  const verifier  = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
}

async function post(url: string, body: unknown): Promise<Record<string, unknown>> {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(body);
    const req = https.request(url, {
      method: "POST",
      headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) },
    }, (res) => {
      let raw = "";
      res.on("data", (c) => (raw += c));
      res.on("end",  () => resolve(JSON.parse(raw)));
    });
    req.on("error", reject); req.write(payload); req.end();
  });
}

const school = "engelska";
const STATE  = crypto.randomBytes(16).toString("hex");
const { verifier, challenge } = makePkcePair();

const auth = await post(
  `https://sms.schoolsoft.se/${school}/rest-api/login/student/password`,
  { username: "anna.lindqvist", password: "s3cr3t",
    code_challenge: challenge, code_challenge_method: "S256",
    client_id: "eApp", redirect_uri: "eapp://login",
    response_type: "code", state: STATE, orgid: school },
);

if (auth["state"] !== STATE) throw new Error("State mismatch — possible CSRF");

const token = await post(
  `https://sms.schoolsoft.se/${school}/rest-api/login/token`,
  { clientId: "eApp", grantType: "code", code: auth["code"], code_verifier: verifier },
);

console.log(token["access_token"]); // Bearer token, valid 30 days

Python

Uses only the Python standard library — no requests or other packages needed.

typescript
import hashlib, base64, json, os, urllib.request

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

school    = "engelska"
verifier  = b64url(os.urandom(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state     = b64url(os.urandom(16))

def post(url, payload):
    data = json.dumps(payload).encode()
    req  = urllib.request.Request(
        url, data=data,
        headers={"Content-Type": "application/json", "User-Agent": "Schoolsoft+/1.0"})
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())

auth = post(
    f"https://sms.schoolsoft.se/{school}/rest-api/login/student/password",
    {"username": "anna.lindqvist", "password": "s3cr3t",
     "code_challenge": challenge, "code_challenge_method": "S256",
     "client_id": "eApp", "redirect_uri": "eapp://login",
     "response_type": "code", "state": state, "orgid": school})

assert auth["state"] == state, "State mismatch"

token = post(
    f"https://sms.schoolsoft.se/{school}/rest-api/login/token",
    {"clientId": "eApp", "grantType": "code",
     "code": auth["code"], "code_verifier": verifier})

print(token["access_token"])  # Bearer token, valid 30 days

curl

Two-step shell script using openssl for PKCE generation and jq for JSON parsing.

bash
# Generate PKCE pair
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=
')
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=
')
STATE=$(openssl rand -hex 16)
SCHOOL="engelska"

# Step 1 — Authorization request
AUTH=$(curl -s -X POST   "https://sms.schoolsoft.se/$SCHOOL/rest-api/login/student/password"   -H "Content-Type: application/json"   -d "{    \"username\": \"anna.lindqvist\",    \"password\": \"s3cr3t\",    \"code_challenge\": \"$CHALLENGE\",    \"code_challenge_method\": \"S256\",    \"client_id\": \"eApp\",    \"redirect_uri\": \"eapp://login\",    \"response_type\": \"code\",    \"state\": \"$STATE\",    \"orgid\": \"$SCHOOL\"  }")

CODE=$(echo "$AUTH" | jq -r '.code')

# Step 2 — Token exchange
curl -s -X POST   "https://sms.schoolsoft.se/$SCHOOL/rest-api/login/token"   -H "Content-Type: application/json"   -d "{\"clientId\":\"eApp\",\"grantType\":\"code\",\"code\":\"$CODE\",\"code_verifier\":\"$VERIFIER\"}"   | jq -r '.access_token'

Go

Standard library only — no external modules required.

typescript
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
)

func b64url(b []byte) string {
	return base64.RawURLEncoding.EncodeToString(b)
}

func makePKCE() (verifier, challenge string) {
	raw := make([]byte, 32); rand.Read(raw)
	verifier = b64url(raw)
	sum := sha256.Sum256([]byte(verifier))
	challenge = b64url(sum[:])
	return
}

func post(url string, body any) map[string]any {
	payload, _ := json.Marshal(body)
	resp, _ := http.Post(url, "application/json", bytes.NewReader(payload))
	defer resp.Body.Close()
	var result map[string]any
	json.NewDecoder(resp.Body).Decode(&result)
	return result
}

func main() {
	school := "engelska"
	stateBytes := make([]byte, 16); rand.Read(stateBytes)
	state := b64url(stateBytes)
	verifier, challenge := makePKCE()

	auth := post(fmt.Sprintf("https://sms.schoolsoft.se/%s/rest-api/login/student/password", school),
		map[string]any{
			"username": "anna.lindqvist", "password": "s3cr3t",
			"code_challenge": challenge, "code_challenge_method": "S256",
			"client_id": "eApp", "redirect_uri": "eapp://login",
			"response_type": "code", "state": state, "orgid": school,
		})

	token := post(fmt.Sprintf("https://sms.schoolsoft.se/%s/rest-api/login/token", school),
		map[string]any{
			"clientId": "eApp", "grantType": "code",
			"code": auth["code"], "code_verifier": verifier,
		})

	fmt.Println(token["access_token"]) // Bearer token
}

Kotlin

JVM/Android example using OkHttp and org.json.

typescript
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64

val JSON_TYPE = "application/json".toMediaType()
val client    = OkHttpClient()

fun b64url(b: ByteArray): String =
    Base64.getUrlEncoder().withoutPadding().encodeToString(b)

fun makePkce(): Pair<String, String> {
    val verifier  = b64url(SecureRandom().generateSeed(32))
    val challenge = b64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
    return verifier to challenge
}

fun post(url: String, body: JSONObject): JSONObject {
    val req = Request.Builder().url(url)
        .post(body.toString().toRequestBody(JSON_TYPE)).build()
    return JSONObject(client.newCall(req).execute().body!!.string())
}

val school = "engelska"
val state  = b64url(SecureRandom().generateSeed(16))
val (verifier, challenge) = makePkce()

val auth = post(
    "https://sms.schoolsoft.se/$school/rest-api/login/student/password",
    JSONObject(mapOf("username" to "anna.lindqvist", "password" to "s3cr3t",
        "code_challenge" to challenge, "code_challenge_method" to "S256",
        "client_id" to "eApp", "redirect_uri" to "eapp://login",
        "response_type" to "code", "state" to state, "orgid" to school)))

val token = post(
    "https://sms.schoolsoft.se/$school/rest-api/login/token",
    JSONObject(mapOf("clientId" to "eApp", "grantType" to "code",
        "code" to auth.getString("code"), "code_verifier" to verifier)))

println(token.getString("access_token")) // Bearer token

Swift

iOS/macOS example using URLSession and CryptoKit (Swift 5.9+).

typescript
import Foundation
import CryptoKit

func b64url(_ data: Data) -> String {
    data.base64EncodedString()
        .replacingOccurrences(of: "+", with: "-")
        .replacingOccurrences(of: "/", with: "_")
        .replacingOccurrences(of: "=", with: "")
}

func makePKCE() -> (verifier: String, challenge: String) {
    var bytes = [UInt8](repeating: 0, count: 32)
    SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
    let verifier  = b64url(Data(bytes))
    let challenge = b64url(Data(SHA256.hash(data: Data(verifier.utf8))))
    return (verifier, challenge)
}

func post(_ urlStr: String, body: [String: Any]) async throws -> [String: Any] {
    var req = URLRequest(url: URL(string: urlStr)!)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try JSONSerialization.data(withJSONObject: body)
    let (data, _) = try await URLSession.shared.data(for: req)
    return try JSONSerialization.jsonObject(with: data) as! [String: Any]
}

let school = "engelska"
let state  = UUID().uuidString.filter { $0.isHexDigit }.lowercased()
let (verifier, challenge) = makePKCE()

let auth = try await post(
    "https://sms.schoolsoft.se/(school)/rest-api/login/student/password",
    body: ["username": "anna.lindqvist", "password": "s3cr3t",
           "code_challenge": challenge, "code_challenge_method": "S256",
           "client_id": "eApp", "redirect_uri": "eapp://login",
           "response_type": "code", "state": state, "orgid": school])

let token = try await post(
    "https://sms.schoolsoft.se/(school)/rest-api/login/token",
    body: ["clientId": "eApp", "grantType": "code",
           "code": auth["code"]!, "code_verifier": verifier])

print(token["access_token"] as! String) // Bearer token

PHP

PHP 8.1+ using only built-in functions — no Composer packages needed.

typescript
<?php

function b64url(string $bytes): string {
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

function makePkce(): array {
    $verifier  = b64url(random_bytes(32));
    $challenge = b64url(hash('sha256', $verifier, true));
    return [$verifier, $challenge];
}

function jsonPost(string $url, array $body): array {
    $ctx = stream_context_create(['http' => [
        'method'  => 'POST',
        'header'  => "Content-Type: application/json

User-Agent: Schoolsoft+/1.0

",
        'content' => json_encode($body),
    ]]);
    return json_decode(file_get_contents($url, false, $ctx), true);
}

$school = 'engelska';
$state  = bin2hex(random_bytes(16));
[$verifier, $challenge] = makePkce();

$auth = jsonPost("https://sms.schoolsoft.se/$school/rest-api/login/student/password", [
    'username' => 'anna.lindqvist', 'password' => 's3cr3t',
    'code_challenge' => $challenge, 'code_challenge_method' => 'S256',
    'client_id' => 'eApp', 'redirect_uri' => 'eapp://login',
    'response_type' => 'code', 'state' => $state, 'orgid' => $school,
]);

$token = jsonPost("https://sms.schoolsoft.se/$school/rest-api/login/token", [
    'clientId' => 'eApp', 'grantType' => 'code',
    'code' => $auth['code'], 'code_verifier' => $verifier,
]);

echo $token['access_token'] . PHP_EOL; // Bearer token

Ruby

Pure Ruby using the standard library — no gems required.

typescript
require 'net/http'
require 'json'
require 'digest'
require 'base64'
require 'securerandom'

def b64url(bytes)
  Base64.urlsafe_encode64(bytes, padding: false)
end

def make_pkce
  verifier  = b64url(SecureRandom.random_bytes(32))
  challenge = b64url(Digest::SHA256.digest(verifier))
  [verifier, challenge]
end

def json_post(url, body)
  uri  = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  req = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/json')
  req.body = body.to_json
  JSON.parse(http.request(req).body)
end

school              = 'engelska'
state               = SecureRandom.hex(16)
verifier, challenge = make_pkce

auth = json_post("https://sms.schoolsoft.se/#{school}/rest-api/login/student/password", {
  username: 'anna.lindqvist', password: 's3cr3t',
  code_challenge: challenge, code_challenge_method: 'S256',
  client_id: 'eApp', redirect_uri: 'eapp://login',
  response_type: 'code', state: state, orgid: school,
})

token = json_post("https://sms.schoolsoft.se/#{school}/rest-api/login/token", {
  clientId: 'eApp', grantType: 'code',
  code: auth['code'], code_verifier: verifier,
})

puts token['access_token'] # Bearer token

C#

.NET 6+ using HttpClient and System.Text.Json — no NuGet packages needed.

typescript
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

static string B64Url(byte[] bytes) =>
    Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');

static (string verifier, string challenge) MakePkce()
{
    var verifier  = B64Url(RandomNumberGenerator.GetBytes(32));
    var challenge = B64Url(SHA256.HashData(Encoding.UTF8.GetBytes(verifier)));
    return (verifier, challenge);
}

static async Task<JsonElement> Post(HttpClient http, string url, object body)
{
    var json     = JsonSerializer.Serialize(body);
    var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"));
    using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
    return doc.RootElement.Clone();
}

var school = "engelska";
var state  = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower();
var (verifier, challenge) = MakePkce();

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("User-Agent", "Schoolsoft+/1.0");

var authEl = await Post(http,
    $"https://sms.schoolsoft.se/{school}/rest-api/login/student/password",
    new { username = "anna.lindqvist", password = "s3cr3t",
          code_challenge = challenge, code_challenge_method = "S256",
          client_id = "eApp", redirect_uri = "eapp://login",
          response_type = "code", state, orgid = school });

var code = authEl.GetProperty("code").GetString()!;

var tokenEl = await Post(http,
    $"https://sms.schoolsoft.se/{school}/rest-api/login/token",
    new { clientId = "eApp", grantType = "code", code, code_verifier = verifier });

Console.WriteLine(tokenEl.GetProperty("access_token").GetString()); // Bearer token